Wikilivres frwikibooks https://fr.wikibooks.org/wiki/Accueil MediaWiki 1.47.0-wmf.22 first-letter Média Spécial Discussion Utilisateur Discussion utilisateur Wikilivres Discussion Wikilivres Fichier Discussion fichier MediaWiki Discussion MediaWiki Modèle Discussion modèle Aide Discussion aide Catégorie Discussion catégorie Transwiki Discussion Transwiki Wikijunior Discussion Wikijunior TimedText TimedText talk Module Discussion module Event Event talk Fonctionnement d'un ordinateur/La désambiguïsation mémoire 0 65845 774129 774089 2026-10-04T13:32:30Z Mewtow 31375 /* La file de µops mémoire */ 774129 wikitext text/x-wiki Dans les chapitres précédents, nous avons vu la mal-nommée exécution dans le désordre dans les chapitres précédents, qui devrait plutôt s'appeler l'émission dans le désordre. Elle modifie l'ordre des instructions pour gagner en efficacité, mais cela n'est valable que pour les instructions travaillant sur des registres ! Nous n'avons pas parlé de l'exécution dans le désordre des accès mémoire, car ils sont un peu à part. Et pour comprendre pourquoi, nous allons dédier ce chapitre à la gestion des accès mémoire dans le pipeline. Avant de poursuivre, faisons un rappel rapide sur les dépendances de données. Il faut distinguer deux types de dépendances de données : les dépendances de registres (deux instructions manipulent le même registre), et les dépendances d'adresse (deux instructions manipulent la même adresse). Deux instructions ont une dépendance d'adresse si elles écrivent ou lisent la même adresse mémoire. Des instructions mémoires qui lisent/écrivent des adresses différentes sont indépendantes. Lorsque deux instructions mémoire lisent/écrivent à la même adresse, on parle d''''aliasing mémoire'''. Formellement, l'aliasing mémoire n'est ni plus moins que l'application des dépendances de données aux instructions mémoire. Et comme les autres dépendances de données, il existe plusieurs types d'aliasing mémoire : RAR, RAW, WAR et WAW. Les dépendances RAR ne posent aucun problème, les lectures peuvent changer d'ordre sans aucun problème, tant qu'il n'y a pas d'écritures entre les deux. Par contre, les autres dépendances imposent trois contraintes. * Les dépendances RAW imposent que les lectures ne doivent pas s'exécuter avant une écriture à la même adresse. * Les dépendances WAR imposent que l'on ne peut pas déplacer une écriture avant une lecture à la même adresse. * Enfin, les dépendances WAW font que l'ordre des écritures doit être celui du programme : impossible de changer l'ordre de deux écritures. Les trois contraintes peuvent s'implémenter de plusieurs manières différentes. Comme pour les dépendances de données normales, seules les dépendances RAW sont de vraies dépendances de données. Les deux autres dépendances viennent du fait que l'on utilise la même adresse pour stocker des valeurs différentes. Détecter les cas d'''aliasing'' mémoire est primordial pour gérer la désambiguïsation mémoire, et cela demande de comparer des adresses. S'il existe des techniques similaires au renommage de registre permettent de les faire disparaitre, elles sont très limitées. Diverses optimisations permettent d'émettre des accès mémoire dans le désordre, exactement comme l’exécution dans le désordre le fait pour les autres instructions. Elles utilisent pour cela des ''schedulers'' spécialisées dans les accès mémoire. Il faut vraiment insister sur le fait que l'exécution dans le désordre proprement dite ne se préoccupe pas des micro-opérations mémoire. Pour faire la distinction, on parle de '''désambiguïsation mémoire''' (''memory disambiguation'') pour parler de l'exécution dans le désordre des accès mémoire. Le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. La raison est que la donnée lue est utilisée par d'autres instructions, dépendantes de la lecture. Elles sont donc à la tête d'une chaine de dépendances de données qui peut bloquer le pipeline si elle n'est pas résolue très tôt. Dès qu'une lecture peut être lancée, elle accède directement au cache de données. Par contre, si l'''aliasing'' ne le permet pas (écriture à la même adresse pas encore effectuée) ou que l'adresse à lire n'a pas encore été calculée, la lecture attend son tour. Le problème est que la désambiguïsation mémoire demande de comparer des adresses, pour détecter les dépendances d'adresse. Et cela pose de sérieuses contraintes pour son implémentation. L'exécution dans le désordre normale compare des registres, ce qui est beaucoup plus simple. Les registres utilisés sont directement encodés dans l'instruction elle-même, ce qui fait qu'on peut détecter les dépendances de registre après le décodage. Et elles peuvent même être éliminées par le renommage de registre pour les dépendances WAW et WAR. Par contre, les adresses ne sont pas encodées dans l'instruction elle-même (sauf avec le cas particulier de l'adressage absolu), mais sont lues depuis les registres ou calculées par une unité de calcul. Les adresses ne sont donc connues qu'une fois que l'instruction a attendu suffisamment de temps dans le ''scheduler''. ==L’émission dans l'ordre des accès mémoire== Avant toute chose, faisons quelques rappels sur l'exécution dans le désordre. Les micro-opérations renommées sont mises en attente avant que les conditions pour leur émission soient remplies. La mise en attente peut se faire soit dans une file de micro-opération, soit dans un ''scheduler''. Une file de micro-opération est une mémoire FIFO qui garde les micro-opération dans leur ordre d'émission, l'ordre du programme. Les micro-opérations sont donc émises dans l'ordre du programme, il n'y a pas d'émission dans le désordre. Par contre, les ''schedulers''s peuvent émettre les micro-opérations dans le désordre, dans un ordre différent de celui du programme. Dans les deux cas, les écritures sont retardées via une file d'écriture, qu'on a vu il y a quelques chapitres et sur laquelle on fera des rappels. ===La file de µops mémoire=== Avec l''''émission dans l'ordre des accès mémoire''', le processeur émet les accès mémoire dans l'ordre du programme. Cela implique que l'unité mémoire n'a pas de ''scheduler'', ni de station de réservation associée. À la place, les micro-opérations mémoire sont placées dans une file de micro-opération dédiée, appelée la '''file de µops mémoire'''. La file de µops mémoire est une mémoire FIFO, dont chaque entrée peut accueillir une µops mémoire. Par contre, le processeur utilise des ''schedulers'' pour les unités de calcul. Autant les accès mémoire se font dans l'ordre, autant les instructions arithmétiques/logiques/branchements/autres sont exécutées dans le désordre. En clair, le processeur supporte l'exécution dans le désordre, sauf pour les micro-opérations mémoire. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] L'unité mémoire ne permet que de faire un accès mémoire à la fois. Du moins, elle a un port sur lequel on envoie la micro-opération émise, que ce soit une écriture ou une lecture. Vu de l'extérieur, il faut attendre qu'un accès mémoire soit terminé pour lancer le suivant. Avant d'émettre une micro-opération mémoire, la file de micro-opération (son ''scoreboard'') vérifie si l'unité d'accès mémoire est libre ou non. Si elle est occupée, un accès mémoire est en cours, et on ne peut pas émettre de micro-opération mémoire. Une fois l'accès mémoire terminé, la mémoire ou le cache envoient un signal pour indiquer que l'accès est fini, et c'est au tour d'une nouvelle micro-opération mémoire. [[File:Unité de gestion des accès mémoires dans l’ordre.png|centre|vignette|upright=2.0|Unité de gestion des accès mémoires dans l’ordre]] Une file de µops pure implique la ''lecture après émission'', à savoir que les µops mémoire quittent la file de µops, avant de lire les registres adéquats. Il y a donc une connexion entre banc de registre et unité mémoire, comme sur n'importe quel autre processeur. Mais la file de µops n'est pas connectée au banc de registre, ni au réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] L'organisation précédente laisse penser que les calculs d'adresse sont forcément réalisés dans l'unité mémoire. Mais ce n'est pas une obligation. Il est parfaitement possible de scinder une µops mémoire en deux : une µop mémoire proprement dite et une µop de calcul d'adresse. La liaison entre les deux doit cependant se faire en utilisant un registre. L'adresse calculée est enregistrée dans un registre d'adresse "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. ===La file d'écriture : une FIFO de mise en attente des écritures=== L'unité mémoire exécute une µops mémoire à la fois. Avec un détail particulier : les écritures doivent attendre que toutes les instructions se terminent, avant de pouvoir s'exécuter. Sans ça, les exceptions précises sont impossibles, la prédiction de branchement l'est aussi et l'exécution dans le désordre aurait des conséquences fâcheuses (en exécutant des écritures en avance). Précisément, les écritures doivent respecter deux contraintes : s'exécuter dans le tout dernier étage du pipeline, s'exécuter dans l'ordre du programme. Il serait possible de bloquer l'émission des écritures tant que le ROB ne donne pas son accord. Mais ce n'est pas du tout ce qui est fait. A la place, les écritures sont mises en attente dans une mémoire FIFO, appelée la '''file d'écriture''', ou encore la ''Write Queue''. Les écritures sont mises en attente, jusqu'à ce que le ROB leur donne le okay pour transfer l'écriture en mémoire RAM. Nous en avions déjà parlé dans le chapitre sur le pipeline, dans la section sur les exceptions précises. Mais quelques rappels ne feront pas de mal. L'écriture est insérée dans la file d'écriture lorsqu'elle est émise, elle en sort à l'étage de ''Writeback'' si tout se passe bien. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente, faites à tort. De plus, la file d'écriture est une mémoire FIFO, qui mémorise les écritures dans l'ordre du programme. En effet, les écritures sont ajoutées dans la file d'écriture lorsqu'elles sont émises, et elles sont émises dans l'ordre du programme. [[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Write Queue sur un pipeline fixe]] La file d'écriture garantit que les écritures sont dans l'ordre du programme. Et cette propriété élimine totalement les dépendances WAW. Le fait que les écritures se font à la fin du pipeline élimine quant à elle les dépendances WAR. Il ne reste plus qu'à gérer les dépendances RAW, celles où une lecture lit une donnée écrite par une autre instruction. Une telle situation survient, car les écritures sont mises en attente pendant un certain temps, la donnée écrite peut être lue pendant ce laps de temps. La lecture ne peut pas lire la donnée dans le cache, car la donnée à lire n'y est pas encore enregistrée. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire, mais il existe. Il se manifeste surtout sur les processeurs avec peu de registre, où des données doivent régulièrement être déplacées temporairement en mémoire RAM. Pour éviter cela, une solution consiste mettre en attente les lectures si une écriture est en attente. Les lectures sont maintenues dans la file de µops tant que la file d'écriture n'est pas vide. La file de µops mémoire est suivie d'un circuit de détection des dépendances, un '''''scoreboard'' mémoire''' qui est assez simple. L'unité mémoire fournit un bit qui indique si la file d'écriture est vide ou non, ainsi qu'un second bit qui indique qu'elle est vide. Les deux ne sont pas compliqués à générer, ils font partie intégrante de l'interface de toute mémoire FIFO digne de ce nom. Le ''scoreboard'' mémoire utilise ces deux bits pour savoir s'il peut émettre une micro-opération mémoire. Il n'émet pas de lecture tant que la file d'écriture n'est pas vide. Il peut par contre émettre des écritures tant qu'elle n'est pas pleine. Une meilleure solution serait d'interdire les lectures, mais seulement en cas de dépendance mémoire détectée. Et c'est là qu'intervient la technique du '''''load bypassing'''''. Elle autorise une lecture, si elle n'a aucune dépendance avec les écritures mises en attente dans la file d'écriture. Elle doit être mise en attente dans le cas contraire. Pour l'implémenter, il faut comparer l'adresse à lire avec toutes les adresses dans la file d'écriture. La lecture est exécutée immédiatement si aucune correspondance n'est trouvée, mais elle est mise en attente dans le cas contraire. Pour cela, la file d'écriture est modifiée et devient un mélange entre FIFO et mémoire associative. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'' : la lecture doit être bloquée. Sinon, c'est un défaut de ''Write Queue'', la lecture accède au cache, là où se trouve la donnée à lire. Dans le chapitre sur le contournement, nous avons parlé d'une forme de contournement qui corrige ce problème. Les lectures peuvent lire la donnée depuis la file d'écriture si besoin, ce qui renvoie la donnée adéquate. Pour cela, les lectures consultent à la fois le cache et la file d'écriture, comme dit plus haut. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'', ou encore de '''réacheminement écriture-vers-lecture'''. [[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]] La lecture est mise en attente dans l'unité mémoire, vu que c'est elle qui fait les comparaisons d'adresse. Du point de vue extérieur, le ''scoreboard'' mémoire ne voit pas s’il y a eu succès dans la file d'écriture ou non. Il voit juste que l'unité mémoire est occupée et ne peut pas accepter de nouvelle lecture. Elle reste occupée durant quelques cycles, en cas de défaut de ''Write Queue'', le temps de faire la lecture dans le cache. Par contre, en cas de succès de ''Write Queue'', elle reste occupée tant que la lecture n'a pas donné de résultat, ce qui inclue le temps de mise en attente. Les processeurs haute performance implémentent tous le ''Store to load Forwarding'', mais quelques processeurs basse performance ne le font pas. Ils se contentent du ''load bypassing'', qui offre des performances correctes pour un cout en matériel bien plus modeste. Pour donner un exemple, la micro-architecture Jaguar d'AMD était dans ce cas. Elle a été utilisée sur les consoles de jeu de 8ème génération, comme la Xbox One et la Playstation 4. Les processeurs haute performance préférent utiliser plus de circuits pour implémenter le ''Store to load Forwarding'', mais l'implémentation n'est souvent pas complète. En effet, l'implémentation est souvent simple et ne prend en compte que des lectures/écritures à des adresses identiques, sans compter que la taille des données doit aussi être identique. Une écriture d'un entier de 32 bit sera contournée et envoyée à une lecture de 32 bits à la même adresse. Mais la lecture lit seulement 16 bits sur les 32 écrits, le contournement peut ne pas marcher. Concrètement, ce cas précis fonctionnera. Mais le cas inverse, avec une lecture lisant plusieurs écritures de taille plus petite, ne marchera pas sur tous les processeurs. Ne parlons pas du cas où les données écrites sont à cheval sur deux lignes de cache. ==L'exécution anticipée des calculs d'adresse== La désambiguïsation mémoire fonctionne d'autant mieux que les adresses à lire/écrire sont connues le plus tôt possible. Pour cela, il est possible de séparer les µops mémoire en deux micro-instructions : une pour le calcul d'adresse et l'autre pour les accès mémoire. Cela permet de calculer les adresses dès que possibles, et donc de vérifier à l'avance si l'adresse calculée a des dépendances avec celles des autres instructions. La détection des dépendances est ainsi anticipée de quelques cycles, permettant un accès anticipé sûr des accès mémoire. On parle de '''calcul anticipé des dépendances'''. ===La ''Load/Store Queue'' : une file de µops améliorée=== Implémenter cette technique demande de faire deux choses. Premièrement, il faut faire les calculs d'adresse en-dehors de l'unité mémoire, soit dans une ALU entière, soit dans une unité de calcul d'adresse dédiée. Deuxièmement, les adresses calculées doivent être mises en attente quelque part, et être liées facilement à la µop mémoire associée. Pour le second point, la file de µops mémoire parait être un bon candidat, mais n'est pas exactement la solution. Une file de µops au sens strict ne mémorise pas les opérandes des µops, donc pas d'adresse. Cela implique la lecture après émission, ce qui veut dire que l'adresse à lire/écrire est lue depuis les registres une fois que la µops sort de la file de µops (idem pour la donnée à écrire). En pratique, cette implémentation rend difficile de faire les calculs d'adresse ailleurs que dans l'unité mémoire. Heureusement, il est possible d'adapter la file de µops pour gérer la lecture avant émission. L'idée est qu'elle mémorise, pour chaque µop mémoire, l'adresse à lire/écrire et d'autres informations. Il ne s'agit donc plus vraiment d'une file de µops mémoire mais quelque chose d'autre, une sorte d'intermédiaire entre une station de réservation et une file de µops. D'un côté, elle fonctionne comme une file de µops car elle émet les µops dans l'ordre du programme. De l'autre, elle mémorise les opérandes des µops mémoire, ce qui en fait une pseudo-station de réservation. Une telle structure est appelée une '''''Load/Store Queue''''' (LSQ). Une LSQ contient plusieurs entrées, chaque entrée pouvant accueillir une µops mémoire et ses opérandes. Une entrée contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures. Les deux derniers champs sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. Elle contient aussi des bits de validité, qui indique si l'adresse ou la donnée sont disponibles, ou si le champ associé est vide. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2|Load Store Queue unifiée]] La LSQ doit faire le lien entre une adresse calculée par l'ALU entière, et l'entrée associée de la LSQ. Pour cela, l'unité d'émission peut associer un '''identifiant LSQ''' à une µops mémoire. L'identifiant est généré à l'émission. Aussi bien la µops de calcul d'adresse que a µop mémoire ont toutes deux le même identifiant. L'identifiant est propagé avec la µop de calcul d'adresse, comme tout signal de commande. En sortant de l'AGU, l’identifiant est transmis à la LSQ en même temps que l'adresse. La LSQ regarde quelle entrée a cet identifiant, et mémorise l'adresse dedans. La gestion des données à écrire se fait elle avec le système de contournement normal. La LSQ sait dans quel registre se trouve la donnée à écrire, elle surveille juste le système de contournement pour détecter quand la donnée est disponible. ===Le contournement des adresses mémoire avec une LSQ=== Avec une LSQ, les registres sont lus quand la µop mémoire est insérée dans la LSQ, si elles sont disponibles. Si elles ne sont pas encore disponibles, elles seront envoyées par le système de contournement plus tard. Cela implique qu'il y ait un système de contournement, qui relie les ALUs entières à la LSQ. Les AGUs envoient leur résultat à la LSQ, qui place alors l'adresse calculée dans la bonne entrée, la bonne "µop". [[File:Load-Store Queue.png|centre|vignette|upright=2|Load-Store Queue]] Une autre solution utilise des ALU spécialisées dans les calculs d'adresse, appelées des ''Adress Generation Unit'', abrévié AGU. L'avantage est que cela simplifie grandement le réseau de contournement : seules les AGU sont connectées à la LSQ. Un autre avantage est lié à l'adressage de la destination. En utilisant des opérations entières usuelles, il faudrait préciser que le résultat est à destination de la LSQ, pour configurer le réseau de contournement. Avec des AGU, les calculs d'adresse sont des micro-opérations différentes des additions et soustraction, elles sont envoyées au AGU spécifiquement, pas besoin de préciser la destination. [[File:ALU et LSQ.png|centre|vignette|upright=2|AGUs et Load-Store Queue.]] Dans les deux cas, les µops mémoire sont alors considérées comme des opérations entières un peu spéciales, gérées par le système d'exécution dans le désordre. Elles peuvent être émises dans le désordre, mais la LSQ se chargera de faire les accès mémoire dans l'ordre. Il y a donc calcul des adresses dans le désordre, mais accès mémoire dans l'ordre. ==Les ''schedulers'' mémoire unifiés== Avec la technique précédente, les µops mémoire s'exécutent dans l'ordre, à savoir que l'on ne peut pas déplacer une lecture avant ou après une autre. Il arrive que des lectures prêtes soient mises en attente, parce qu'une lecture précédente attend ses opérandes. Et ce retard sur les lectures se répercute sur les instructions lecture-dépendantes, donc sur le reste du pipeline.Les conséquences sur les performances sont assez importantes. Mais avec l'implémentation qui va suivre, il est possible exécuter les lectures dans le désordre tant que les conditions d'''aliasing'' sont respectées. L'implémentation remplace les files de µops mémoire par un ''scheduler'' spécialisé dans les µops mémoire. Il sera appelé le '''''scheduler'' mémoire''' dans ce qui suit. [[File:Processeur avec émission dans le désordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans le désordre des accès mémoire]] Une entrée du ''scheduler'' mémoire contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures (inutile pour les écritures). Elle contient aussi des bits de validité pour chaque champ qui indiquent si l'adresse d'accès est connue, ou si la donnée à écrire est disponible pour une écriture. Les champs pour la donnée à écrire ou le registre de destination sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2.5|''Load/Store Queue''.]] Lorsqu'une micro-opération mémoire est émise, elle est insérée dans le ''scheduler'' mémoire. Si l'adresse à lire/écrire est disponible, elle est immédiatement lue depuis les registres, idem pour une éventuelle donnée à écrire. Sinon, la micro-opération attend dans le ''scheduler'' mémoire que son adresse soit calculée par le processeur. ===Les calculs d'adresse avec un ''scheduler'' mémoire=== Le ''scheduler'' mémoire détecte des dépendances d'adresse entre instructions. Pour cela, il compare toutes les adresses à lire/écrire. Il mémorise donc les dites adresses, ce qui en fait une station de réservation. Et cela implique que ces adresses ont été calculées ailleurs, dans une ALU entière, ou dans une AGU séparée de l'unité mémoire. L'implémentation la plus simple effectue les calculs d'adresse dans les ALU entières. La sortie de toutes les ALU entière est reliée au ''scheduler'' mémoire. [[File:Contournement pour la file de µops mémoire.png|centre|vignette|upright=2|''scheduler'' mémoire]] Une autre implémentation utilise des unités de calcul d'adresse dédiées, afin de simplifier le réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse via AGU.png|centre|vignette|upright=2|ALU et ''scheduler'' mémoire]] Une conséquence est que l'implémentation impose que toutes les instructions mémoire sont découpées en deux µops : une µop pour calculer l'adresse, et une µop mémoire. C'est un défaut assez important, car une instruction mémoire prendra deux places dans le ROB, dans les stations de réservation, etc. Vu que les instructions mémoire sont très fréquentes, la capacité du ROB/autres est alors fortement réduite. Il faut noter que les adresses calculées dans l'ALU/AGU doivent atterrir dans l'entrée adéquate, celle de la µops qui doit recevoir l'adresse. La seule solution est que le ''program counter'' de la µops est transmis dans le pipeline, propagé avec la µops de calcul d'adresse. Quand le résultat est calculé, le ''program counter'' associé est envoyé avec au ''scheduler'' mémoire. Le ''scheduler'' mémoire enregistre alors l'adresse dans l'entrée qui est taguée avec cette adresse. ===La détection des dépendances mémoire=== Le ''scheduler'' mémoire intègre un système de détection des dépendances, qui est bien différent de celui d'une station de réservation normale. Les ''schedulers'' mémoire comparent non pas des noms de registres, mais des adresses/opérandes. Pour détecter les dépendances, le ''scheduler'' mémoire compare les adresses à lire/écrire. Pour les µops dont l'adresse est inconnue, le cas dépend de l'implémentation. La version la plus conservatrice suppose qu'elle a des dépendances possibles avec toutes les autres. Chaque micro-opération en attente compare son adresse avec celle de toutes les micro-opérations précédentes. Ou presque, la comparaison ne doit tenir compte que des écritures, et de celles situées avant dans l'ordre du programme. Le ''scheduler'' mémoire doit donc mémoriser les instructions dans l'ordre du programme, tout en faisant des comparaisons d'adresse. Ça en fait une sorte hybride entre mémoire associative et mémoire FIFO. Nous avions vu dans le chapitre "L'exécution dans le désordre" qu'il y a plusieurs manières pour implémenter une station de réservation : avec une mémoire associative, avec préplanification, avec des matrices de dépendances. Le problème est qu'on ne peut pas utiliser une mémoire associative pour le ''scheduler'' mémoire, qui utilise des comparateurs pour comparer les noms de registres/adresses. En effet, la détection des dépendances mémoire demande de faire beaucoup de comparaisons : chaque adresse est comparé à toutes les autres. Et les adresses faisant facilement 32 à 64 bits, les comparateurs sont bien plus imposants que ceux qui comparent des noms de registres de 5-10 bits. Le cout en matériel serait trop élevé, de même que le temps de calcul des dépendances. À la place, on utilise des matrices de dépendances. La détection des dépendances mémoire utilise précisément deux matrices de ce type : une matrice de disponibilité, et une matrice de dépendance. Ces matrices forment une espèce de tableau carré, organisé en lignes et en colonnes. L'instruction dans la énième entrée se voit attribuer la énième ligne et la énième colonne. À l'intersection d'une ligne et d'une colonne, on trouve un bit qui indique si l'instruction de la ligne et celle de la colonne ont une dépendance. La '''matrice de disponibilité''' permet de bloquer l'exécution des lectures/écritures tant qu'une instruction précédente n'a pas calculé son adresse, tant qu'on ne peut pas vérifier les dépendances. Lorsqu'une instruction est ajoutée dans le ''scheduler'' mémoire, elle met tous les bits de la colonne attribuée à 1 si l'adresse à lire/écrire est inconnue, puis les met à 0 une fois l'adresse calculée. Une instruction ne peut pas démarrer si, sur la ligne qui lui est attribuée, se trouve un 1. Plus précisément, si on trouve un 1 pour les instructions plus anciennes, ce qui fait qu'une partie de la matrice est ignorée. Les bits notés X dans le tableau suivant sont ignorés, car ils précisent la disponibilité des opérandes d'un opérande ultérieure, ce qui est inutile. En clair, les calculs d'adresse modifient les colonnes, et on vérifie les lignes pour autoriser l'exécution. {|class="wikitable" |- ! Instructions mémoire dans la LSB, triées de la plus ancienne à la plus jeune ! 0 !! 1 !! 2 !! 3 !! 4 !! 5 !! 6 |- ! 0 | 1 || X || X || X || X || X || X |- ! 1 | 0 || 0 || X || X || X || X || X |- ! 2 | 0 || 1 || 0 || X || X || X || X |- ! 3 | 0 || 1 || 1 || 1 || X || X || X |- ! 4 | 0 || 0 || 0 || 0 || 1 || X || X |- ! 5 | 0 || 0 || 1 || 0 || 0 || 1 || X |- ! 6 | 1 || 1 || 1 || 1 || 1 || 1 || 0 |} Les '''matrices de dépendances''' fonctionnent sur le même principe, mais déterminent les dépendances mémoire. Lorsque le processeur a calculé une adresse, il la compare avec les adresses à lire/écrire déjà dans le ''scheduler'' mémoire. Il met alors à jour les bits de la colonne de la matrice de dépendance : le bit à l'intersection d'une ligne et d'une colonne est mis à 0 si les deux instructions n'ont pas de dépendances, à 1 si elles en ont une. Une instruction mémoire est exécutée si tous les bits de sa ligne sont à 0. Là encore, on ne tient compte que des bits pour les instructions plus anciennes, la matrice a encore une fois une forme triangulaire, comme pour la matrice de disponibilité. Une optimisation scinde le ''scheduler'' mémoire en deux structures, aux rôles légèrement différents. La première gère la disponibilité des opérandes, la seconde gère seulement les dépendances mémoire. La première est une station de réservation où les instructions sont mises en attente tant que leurs opérandes ne sont pas prêts. La seconde met en attente des micro-opérations dont les opérandes sont prêts, mais qui doivent attendre pour une autre raison : dépendance mémoire, cache occupé, etc. L'avantage est qu'on remplace une grosse station de réservation par deux plus petites, mais l'intérêt est surtout relié au système de détection des dépendances. Sans cette optimisation, on a deux matrices énormes reliées à une ''Load/Store Queue'' unique. Avec la séparation, la matrice de disponibilité est reliée uniquement à la première station de réservation, alors que la matrice de dépendance est reliée uniquement à l'autre. Les deux matrices sont donc plus petites, car reliées à deux stations de réservation mémoire plus petites. ==L'exécution spéculative des accès mémoires== Pour rappel, le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. Les données lues sont en effet, le départ d'une série d'instructions dépendantes, généralement des instructions arithmétiques, mais parfois d'autres lectures. Quand on manipule des structures de données, il n'est pas rare qu'une lecture lise un pointeur-adresse utilisé par une autre lecture. Les techniques de désambiguïsation mémoire que nous avons vu précédemment sont dites conservatrices, à savoir qu'elles ne changent pas l'ordre de deux instructions mémoire si elles ont une dépendance d'adresse. Elles exécutent une lecture le plus tôt possible, mais seulement si on sait que celle-ci n'aliase pas d'écritures précédentes. Et cela demande de connaitre les adresses des écritures précédentes dans l'ordre du programme. Si une lecture est précédée par une ou plusieurs écritures, les adresses à écrire doivent être connues. Il se peut en effet qu’une écriture dont on ne connait pas l'adresse aliase la lecture. La garantie est liée à la présence de la file de µops mémoire ou la file de µops STORE. Si une seule écriture a une adresse inconnue, elle reste dans la file de µops mémoire et bloque l'émission de toutes les autres micro-opérations mémoire, lecture comme écriture. Mais il existe des techniques dites spéculatives, qui modifient l'ordre des accès mémoire sans se soucier de leurs dépendances. Elles émettent les lectures dès que leurs opérandes sont prêts, sans se préoccuper de l'aliasing avec une écriture précédente. Pour éviter tout problème, le processeur vérifie si aucune dépendance mémoire n'est violée et corrige le tir dès qu'une violation est détectée. Le processeur vérifie s'il a fait une erreur de prédiction, en vérifiant les adresses à écrire ou lire. En cas d'erreur, le processeur fait comme lors d'une prédiction de branchement ratée : il remet le pipeline en ordre et ré-exécute les accès mémoire dans le bon ordre. Il s'agit d'un cas particulier d’'''exécution spéculative''', au même titre que la prédiction de branchement. Toutes les techniques de ce genre exécutent des lectures dans le désordre. L'avantage est que cela permet d'exécuter les lectures en avance. Vu que la donnée lue est l'opérande d'autres instructions, les exécuter en avance fait prendre de l'avance à un paquet d'instructions. Mais il faut comparer cet avantage au risque de mauvaise prédiction. Et aussi bizarre que cela puisse paraître, cela donne de bons résultats. Il faut dire que les situations où une lecture relit une donnée tout juste écrite sont rares : même pas 2 à 3 % des lectures. Les dépendances mémoire sont donc très rares, les cas d'''aliasing'' sont vraiment limités. En termes de cout en transistors, le hardware qui vérifie les dépendances mémoire reste globalement le même à peu de choses près, il est juste déplacé dans le pipeline et adapté à sa nouvelle fonction. ===La file de lectures=== La technique que nous allons voir émet les lectures dans l'ordre, ce qui simplifie énormément la gestion des dépendances. Les lectures sont émises dans l'ordre, les écritures aussi. Par contre, l'ordre entre lectures et écritures n'est pas respecté. Une lecture peut passer avant une écriture sans problèmes, voire avant plusieurs écritures d'un seul coup. Une telle lecture exécutée en avance d'une autre écriture est appelée une '''lecture spéculative'''. Une lecture spéculative peut donc sauter devant une ou plusieurs écritures, qui seront appelées des '''écritures sautées'''. Une écriture sautée est une précédant une lecture spéculative, dans l'ordre du programme. Évidemment, il se peut qu'une écriture sautée écrive la donnée qui est lue par la lecture spéculative. Il y a alors une violation de dépendance mémoire. Mais le processeur suppose qu'il n'y a pas de dépendantes de ce type et émet la lecture en avance. Rassurez-vous, il surveille quand même la situation, pour tout annuler ensuite s'il détecte une violation de dépendance mémoire. J'ai dit plus haut que le processeur émet les lectures dans l'ordre, ainsi que les écritures dans l'ordre. Pour cela, la LSQ est scindée en deux files de µops : une pour les µops LOAD et une autre pour les µops STORE. Les cours d'architecture des ordinateurs nomment souvent ces deux files en utilisant les termes de ''file de STORE'' (''store queue'') et de ''file de LOAD'' (''load queue''), mais il y a un risque de confusion avec la file d'écriture vue précédemment, et la file de lecture qu'on introduira dans la suite. Dans ce qui suit, nous parlerons de '''file de µops LOAD''' et de '''file de µops STORE''', pour éviter toute confusion. Les deux files sont chacune connectées à l'unité mémoire, via un port dédié. L'unité mémoire a donc un port pour les lectures, et un port pour les écritures. Le port d'écriture est relié à la file d'écriture, le port de lecture est directement relié au cache de données. Il faut noter qu'une unité mémoire à deux ports n'est pas si bizarre que cela. En effet, le cache de données a lui aussi deux ports, dans la majorité des cas, avec un port de lecture et un port d'écriture. [[File:Load Ordering, Store Ordering.jpg|centre|vignette|upright=2|Exécution dans le désordre des lectures.]] Maintenant, voyons comment sont détectées les violations de dépendances mémoire. Les dépendances qui peuvent être violées dans ce cas de figure se font entre une écriture sautée et une lecture spéculative. Si une lecture spéculative lit une donnée qui est écrite par une écriture sautée, alors une dépendance RAW a été violée. Pour les détecter, le processeur garde une trace des lectures spéculatives; tant que les instructions précédentes ne sont pas terminées. Pour cela, il utilise une file d'attente, qui mémorise les lecture spéculatives. Nous l'appellerons la ''file de vérification des lectures'', ou encore la '''file de lectures'''. Elle est une sorte d'équivalent de la file d'écriture, mais pour les lectures. Les lectures sortent de la file µops LOAD dès que leurs adresses sont calculées, sans tenir compte des dépendances mémoires. Elles sont alors ajoutées à la file de lectures et s'exécutent immédiatement, de manière spéculative. Pour vérifier qu'il n'y a pas d'erreur de spéculation, le processeur conserve l'adresse de la lecture effectuée dans la file de lectures. Les lectures quittent la file de lecture quand toutes les instructions précédentes dans l'ordre du programme sont terminées, à savoir tant quand elles ont quitté le tampon de réordonnancement/ROB. Notons que la file de vérification des lectures contient les lectures dans l'ordre du programme. En effet, les lectures sont accumulées en sortant de la file de µops LOAD, où elles sont triées dans l'ordre du programme. Et cela permet de gérer les erreurs de prédiction parfaitement, en annulant les instructions suivant une erreur de prédiction. [[File:Spéculation avec une file de lecture.jpg|centre|vignette|upright=2.5|Spéculation avec une file de lecture.]] Reste à détecter les erreurs de prédiction, ce qui peut être fait avec deux méthodes. La première détecte les violations de dépendances mémoire à chaque écriture. L'idée est que chaque écriture vérifie si une lecture anticipée n'a pas lue la donnée écrite en avance. Pour cela, quand une écriture est émise, elle consulte la file de lecture pour voir s'il y a un conflit d'adresse. La vérification fait cependant gaffe à ne prendre en compte que les lectures situées après l'écriture dans l'ordre du programme. Précisément, elle envoie l'adresse à écrire à la file de lecture et cherche si une lecture ultérieure a eu lieu à la même adresse. Si c'est le cas, cela signifie qu'une lecture anticipée aurait du lire la donnée actuellement écrite, mais que la lecture a été exécutée trop en avance. La lecture n'a pas lu la bonne donnée, il y a eu une violation de dépendance mémoire. Pour faire cette vérification, la file de lecture est un mélange entre FIFO et cache/mémoire associative, sur le même modèle que la file d'écriture. La différence est que la consultation de la file de lecture ne sert pas à faire du contournement, mais à détecter les dépendances. Quand une dépendance est détectée, l'écriture se fait normalement. Par contre, la lecture marquée comme invalide dans la file de lecture. L'exécution du programme se poursuit, jusqu'à ce que la lecture arrive à l'étape de ''commit'', à savoir que le ROB lui dit que les instructions précédentes sont terminées sans encombre. Là, la lecture étant invalide, le processeur déclenche une procédure de réparation, qui vide partiellement le pipeline de toutes les instructions qui suivent la lecture. La lecture est de plus ré-executée, afin de lire la bonne donnée. Avec la seconde méthode, les violations de dépendance mémoire sont détectées au moment où la lecture quitte à la file de lectures et le ROB. L'idée est de comparer la donnée lue de manière spéculative avec la donnée lue de manière non-spéculative. Pour faire la comparaison, la lecture spéculative copie la donnée lue dans la file de lecture. Quand la lecture quitte la file de lecture et le ROB, la lecture est ré-exécutée et la donnée relue. Le processeur compare alors la donnée lue à ce moment avec la donnée mémorisée dans la file de lecture. Si la donnée est différente, alors il y a eu erreur de violation de dépendance mémoire. Évidemment, relire une donnée n'est pas gratuit. Il est possible de limiter son impact en performance en dédiant un port de lecture sur le cache rien que pour ça. Mais le cout en circuit est comparable à l'usage d'une FIFO associative de la technique précédente. Mais quoi qu'il en soit, le cache doit permettre des lectures en un seul cycle, sans quoi la méthode ne marche pas vraiment. ===La prédiction de dépendances mémoires=== Certains processeurs essayent de prédire si deux accès mémoires sont dépendants : ils incorporent une unité qui va fonctionner comme une unité de prédiction de branchement, à la différence qu'elle prédit les dépendances mémoires. Si cette unité prédit que deux accès mémoires sont indépendants, le processeur les exécute dans le désordre, et les exécute dans l'ordre du programme dans le cas contraire. Il faut prendre en compte les erreurs de prédiction, ce qui est fait comme pour les mauvaises prédictions de branchement : on vide le pipeline. Beaucoup de techniques de prédiction des dépendances mémoire ont été inventées et en faire une revue exhaustive serait long et fastidieux. On pourrait citer les ensembles colorés (''color sets''), et bien d'autres techniques. Mais nous n'allons parler que des techniques les plus élémentaires. Quoi qu’il en soit, la recherche sur le sujet est assez riche, comme toujours quand il s'agit d'architecture des ordinateurs. On peut améliorer cette technique en mémorisant les instructions qui ont causé une mauvaise prédiction de dépendance mémoire. La prochaine fois qu'on exécute ces instructions, on sait qu'il y a de grandes chances qu'il se produise une erreur de prédiction, ce qui pousse à ne pas les exécuter de manière spéculative. On peut pour cela réutiliser les techniques de prédiction de branchement, tels des compteurs à saturations, mis à jour en cas d'erreurs de prédiction de dépendances mémoire. Dans tous les cas, on trouve un cache, équivalent au ''branch target buffer'', qui mémorise les instructions fautives (leur ''Program Counter''), avec d'autres informations comme les adresses des instructions dépendantes. La première classe de techniques du genre consiste à mémoriser les lectures qui ont causé une violation de dépendance, tandis que l'autre ne mémorise que les écritures. Cette dernière est l'approche utilisée par le '''cache de barrières d’écriture''' (store barrier cache). Enfin, nous allons conclure avec une dernière technique : celle des '''ensembles d’écritures''' (store sets). Cette technique est capable de gérer le cas où une lecture dépend de plusieurs écritures : le processeur mémorise l'ensemble des écritures avec lesquelles la lecture a déjà eu une dépendance. Cet ensemble d'écritures associées à une lecture est appelé tout simplement un ensemble d’écritures, et reçoit un identifiant (un nombre) par le processeur. Cette technique demande d'utiliser deux tables : * une qui assigne un identifiant d’ensemble d’écritures à l'instruction en cours ; * une autre qui mémorise les ensembles d’écritures sous la forme d'une liste chainée : le début de la liste correspond à l'écriture la plus ancienne de l’ensemble. Cela permet d'obtenir l'écriture la plus ancienne dans cet ensemble d’écritures directement. Quand le processeur détecte une violation de dépendance entre une lecture et une écriture, elle ajoute l'écriture dans l’ensemble d’écritures adéquat. Le déroulement d'une lecture demande d'accéder à la première table pour récupérer l'identifiant de l’ensemble d’écritures, et l'utiliser pour adresser la seconde table. S'il y a dépendance, cet accès renvoie l'écriture fautive en cas de dépendance. Quand une écriture est envoyée à l'unité mémoire, celle-ci va accéder à la table de correspondances de la même manière qu'une lecture, et va récupérer l'identifiant de l’ensemble d’écritures auquel elle appartient, identifiant qui est utilisé pour vérifier s'il y a une écriture en cours d’exécution. Si c'est le cas, cela signifie que l'écriture en cours d’exécution doit s’exécuter avant l'écriture qui a consulté la table : cette dernière est mise en attente. ==La prédiction d'adresse et de valeur== D'autres techniques de prédiction mémoire plus élaborées que les précédentes existent. Il s'agit de la prédiction d'adresse et de la prédiction de valeur. Leur nom trahit ce qu'elles font. La première technique tente de prédire l'adresse d'une lecture/écriture, alors que la seconde tente carrément de prédire quelle sera la donnée lue. Il y a bien de la recherche académique sur le sujet, mais il s'agit de techniques assez osées, dont on voit mal comment elles pourraient fonctionner. L'efficacité de cette technique a été étudiée grâce à des simulateurs. Suivant les études ou les programmes, on trouve des résultats qui varient pas mal. Dans certains cas, la performance baisse un peu, et dans d'autres, on peut avoir des gains de plus de 10% ! Mais dans des cas normaux, on trouve des gains de 4-5% environ. Ce qui est tout de même pas mal à l'heure actuelle. Cependant, en Janvier 2025, à l'heure où j'écris ces lignes, il a été découvert la première utilisation de ces deux techniques dans un processeur commercial. La découverte fait suite à la publication de deux vulnérabilités matérielles sur les CPU Apple M2 et M4 et quelques CPU ARM, qui font justement usage de ces deux techniques. Les deux vulnérabilités, appelées SLAP et FLOP, sont assez similaires aux attaques Spectre et Meltdown, sauf qu'elles attaquent non pas la prédiction de branchement, mais la prédiction d'adresse et de valeur du CPU. ===La prédiction d'adresse=== La prédiction d'adresse tente de prédire l'adresse d'une lecture à l'avance, ce qui permet de lancer les lectures en avance. Si la prédiction est correcte, on gagne quelques cycles qui permet aux unités de calcul de faire leurs calculs plus en avance, sans compter que les mécanismes de désambiguïsation mémoire fonctionnent plus efficacement. Si jamais cette adresse est connue plus tôt, on détecte les dépendances plus rapidement et on peut agir en conséquence. Des variantes de ces unités de prédiction d'adresse sont utilisées pour précharger des données depuis le cache de donnée. Et leur efficacité est assez bonne, voire excellente dans certains cas. La différence est que la prédiction d'adresse est utilisée pour prédire les adresses à lire depuis le cache vers les registres, ce qui est le sujet de cette sous-partie. La prédiction d'adresse est réalisée par une sorte de cache, qui associe à chaque instruction la prochaine adresse à lire. Nous appellerons ce cache la '''table de prédiction d'adresse'''. Pour faire l'association instruction <-> adresse lue/écrite, le tag du cache utilise l'adresse de l'instruction (le ''program Counter''), alors que la ligne de cache associée contient l'adresse prédite. Son contenu est utilisé pour faire une prédiction. Lorsqu'une lecture est émise, la table est consultée pour récupérer en avance l'adresse à lire, avant que celle-ci soit calculée par l'ALU. La lecture est alors exécutée en avance, avant que la vraie adresse soit connue, en utilisant l'adresse prédite. Reste qu'il faut des circuits autour de cette table de prédiction d'adresse, pour déterminer si la prédiction est correcte, ainsi que pour remplir cette table avec une adresse prédite. Pour cela, il y a un circuit de prédiction d'adresse dédié. Il récupère les instructions qui quittent le ROB ou la file des lectures mémoire, afin de vérifier les lectures qui viennent de se terminer/''commit'' et qui quittent le pipeline. Il peut contenir une '''table d'apprentissage''', qui mémorise des informations nécessaires pour faire des prédictions d'adresse. Par exemple, un historique des dernières adresses lues pour les lectures les plus récentes, ou un historique des adresses des lectures. La table est aussi utilisée pour déterminer si les prédictions effectuées étaient correctes ou non. Pour cela, il est possible de réutiliser les techniques vues dans le chapitre sur la prédiction de branchement, notamment des compteurs à saturation. Pour la prédiction de branchement et les autres formes de prédiction mémoire, les table d'apprentissage et pour les instructions/adresses sont fusionnées en une seule. Mais ici, il est nécessaire de les séparer pour une raison assez particulière : éliminer la pollution du cache dans la table de prédiction d'adresse. On ne peut pas allouer une ligne de cache pour chaque nouvelle instruction de lecture exécutée. Seule une minorité des lectures profitent de la prédiction d'adresse. Il est nécessaire de filtrer les lectures pour ne conserver que celles qui profitent de la prédiction d'adresse, ce qui est possible plus facilement en séparant la table d'apprentissage et les compteurs à saturation de la table de prédiction d'adresse. Une première méthode consiste à prédire que la lecture lit toujours la même adresse, ce qui lui vaut le nom de '''prédiction d'adresse constante'''. On pourrait se demander pourquoi une telle situation surviendrait. La raison est qu'il arrive que les compilateurs n'arrivent pas à gérer les accès mémoires efficacement. Dans certaines situations un peu compliquées impliquant des pointeurs ou des références, divers phénomènes complexes d'''aliasing'' des pointeurs peuvent générer des relectures intempestives de données en mémoire. Cela peut aussi arriver lorsqu'on compile du code qui provient de plusieurs librairies, ou quand du code lit régulièrement des constantes en mémoire ROM. La prédiction d'adresse constante ajoute un circuit qui analyse les dernières lectures, une fois qu'elles quittent le ROB, qu'elles ''commit''. Si, pour une instruction de lecture, l'adresse lue est toujours la même, alors l'instruction est ajoutée dans la table de prédiction d'adresse. Vu que les instructions qui accèdent toujours à la même adresse sont rares, il est préférable d'initialiser les compteurs à saturation de façon à ce qu'ils disent que toute nouvelle instruction change d'adresse. Il s'agit d'une méthode simple mais peu efficace, car cette situation est rare. Une autre méthode détecte les accès en enjambées, là encore quand les lectures quittent le ROB. Nous l’appellerons la '''prédiction d'adresse en enjambées'''. De tels accès surviennent régulièrement quand on accède des tableaux ou d'autres structures de données semblables, ce qui rend la technique très intéressante. Implémenter cette technique est facile : il suffit d'ajouter un circuit qui détecte les accès en enjambées, au lieu de celui qui détecte les accès constants. À chaque fois qu'une lecture entraine un succès de cache dans la table de prédiction d'adresse, l'enjambée est ajoutée à l'adresse contenue dans la table de prédiction d'adresse, l'ancienne adresse prédite est remplacée par celle avec l'enjambée d'ajoutée. Sur les Apple M2-M4, la prédiction d'adresse gére aussi bien les adresses constantes que les accès en enjambée. Le brevet qui décrit l'implémentation de l'unité de prédiction d'adresse et de valeur est potentiellement le suivant : [https://patents.google.com/patent/US11829763B2/en Early load execution via constant address and stride prediction]. En théorie, il est possible de faire mieux en repérant des accès mémoires qui se répètent de façon régulière et cyclique, même s'ils n'ont aucune enjambée. Ce genre d'accès se trouve assez souvent lorsque l'on manipule des listes chainées ou des structures de données assez irrégulières comme des arbres, des graphes, etc. Mais il n'y a pas encore de CPU qui implémente de telle optimisation. ===La prédiction de valeur : prédire la donnée lue=== Les techniques de '''prédiction de valeur''' (''Value Prediction'') consistent à prédire quelle est la donnée qui sera lue par une instruction de lecture. Oui, vous avez bien lu : le processeur est capable de parier sur la valeur qui sera chargée depuis la mémoire, de prédire si cette valeur vaut 0, 1, 1024, etc. Une fois son pari fait, il exécute les instructions du programme avec la valeur qu'il a pariée de façon spéculative. Si le pari est correct, le CPU continue l’exécution. Sinon, il est obligé de vider le pipeline et de recommencer avec la bonne valeur. Au premier abord, cette technique semble franchement mal partie tellement les chances de se tromper sont tellement énormes ! Mais le fait est que, dans certains cas, il est possible de spéculer correctement. Là encore, les techniques les plus simples fonctionnent dans deux cas. Le premier est celui où une donnée est relue à l'identique à chaque fois que la lecture est exécutée. Le second est celui où la donnée est incrémentée/décrémentée d'une constante qui est toujours la même d'une exécution de la lecture à l'autre. Une sorte d'enjambée constante, mais pour les données. Nous allons nous concentrer sur le premier cas, celui où une donnée est relue plusieurs fois sans être modifiée entre temps. L'idée de base est qu'une instruction de lecture qui s'exécute plusieurs fois de suite à la même adresse renvoie la même valeur à chaque fois. C'est parfaitement possible s’il n'y a eu aucune écriture à cette adresse entre-temps, d'où le fait qu'il faille surveiller si les prédictions constantes sont correctes. On peut se demander quelles sont les raisons qui font qu'une instruction de lecture renvoie la même valeur à chaque fois. Après tout, autant lire une seule fois la donnée et la garder dans un registre une bonne fois pour toutes ! Mais dans la réalité, on fait face à quelques limites, qui seront détaillées dans la liste suivante. La première est que le manque de registre fait que certaines données sont conservées en RAM et sont relues fréquemment. Cela arrive quand on stocke des constantes en mémoire. Par exemple, sur les processeurs x86, les constantes flottantes ne peuvent pas être intégrées dans nos instructions via le mode d'adressage immédiat. À la place, on les stocke en mémoire et on les charge dans les registres à chaque fois qu'on en a besoin. L'implémentation est similaire à la prédiction d'adresse, si ce n'est que c'est la donnée lue qui est prédite. Là encore, on a une '''table de prédiction de donnée lue''', consultée lorsqu'une lecture est émise. La table de prédiction de la donnée lue est une mémoire cache qui associe le ''Program Counter'' de la lecture à la donnée prédite, éventuellement couplé à des compteurs à saturation. Elle est consultée à chaque fois qu'une lecture est émise, la donnée adéquate est récupérée lors d'un succès de cache. On trouve aussi un circuit qui insère ou retire des instructions de la table de prédiction de donnée lue. Elle détecte les lectures prédictibles, couplé à une table d’apprentissage, et quelques circuits annexes liés au ROB ou aux files de lectures. Elle contient au minimum des compteurs à saturations, associés chacun à une instruction (son ''program counter'' pour être précis). ==L'optimisation du ''Mirroring memory operand''== Les processeurs modernes implémentent une optimisation concernant les accès mémoire. Cette optimisation a été décrite par Agner Fog dans ses manuels d'optimisation, sous le terme de '''''Mirroring memory operand'''''. Il a documenté son apparition sur les processeurs Zen 2 d'AMD, puis son évolution sur les processeurs Zen 4 et 5. L'optimisation est bizarrement absente de la microarchitecture Zen 3. Elle se manifeste dans une condition très précise : plusieurs instructions proches accèdent à la même adresse. Les deux instructions ne sont pas forcément consécutives, il peut y avoir une dizaine ou centaine d'instructions entre les deux. Une telle situation survient souvent sur les CPU CISC, et notamment sur les CPU x86, car ils ont peu de registres. les compilateurs font donc beaucoup d'échanges entre pile d'appel et registres, ce qui fait que des données écrites dans la pile sont souvent relue quelques instructions plus tard. Dans ce cas, la donnée lue/écrite est mémorisée dans un registre interne au processeur et peut être accédée très rapidement. Par exemple, imaginons plusieurs lectures proches à la même adresse, sans écriture entre elles. Dans ce cas, le processeur ne fera qu'une seule lecture en mémoire RAM, les lectures ultérieures liront la donnée depuis le registre interne. Comme autre exemple, imaginons une écriture suivie plus tard par une lecture à la même adresse. Dans ce cas, la donnée sera écrite dans un registre interne, en plus d'être propagée dans le cache ou la RAM, et la lecture ultérieure lira directement le registre interne. Le gain en performance est alors conséquent, comparé à du simple ''store-to-load forwarding''. Il faut noter que la technique marche aussi pour les opérations ''load-op'', pas seulement pour les instructions d'accès mémoire. Par exemple, prenons une écriture en RAM, suivies quelques instructions plus tard par une opération ''load-op'' à la même adresse. L'écriture écrit à une adresse, qui est lue par l'instruction ''load-op''. L'optimisation s'applique : l'instruction ''load-op'' lira l'opérande mémoire depuis un registre interne du processeur, qui contient une copie de la donnée écrite. Sur les processeurs Zen 2, l'optimisation marchait aussi pour les instructions PUSH et POP, qui lisaient/écrivaient dans la pile d'appel. Mais cette possibilité a été retirée sur les processeurs Zen 5. La technique détecte que deux instructions accèdent à la même adresse sous certaines conditions bien précises. L'optimisation ne marche que pour les modes d'adressages indicés et indirect, avec ou sans décalages, mais pas en mode absolu ou relatifs. Il faut préciser que l'optimisation ne marche que pour les registres généraux, pas les registres flottants. Les registres doivent être précisés de la même manière dans toutes les instructions. Par exemple, le processeur ne reconnait pas que l'addition des deux registres RCX + RAX est la même que RAX + RCX. Sur les CPU Zen 2 et 4, elle ne marchait que pour des opérandes de 32 et 64 bits, mais les processeurs Zen 5 gèrent des données de 8 et 16 bits aussi. Les décalages étaient limités à 8 bits sur le Zen 2, mais n'a plus de limites sur les Zen 4 et 5. ==La désambiguïsation mémoire a des effets de bord== La désambiguïsation mémoire a une caractéristique que l'exécution dans le désordre simple n'a pas : elle a des effets qui débordent sur la mémoire. Il est possible de savoir si un processeur fait de la désambiguïsation mémoire en regardant le bus mémoire. On s’aperçoit alors que les lectures se font dans un ordre différent de celui du programme. À l'opposé, l'exécution dans le désordre n'a pas d'effets observables de ce genre. Elle améliore la performance, elle modifie les durées observables des instructions, mais cela se limite à des questions de ''timings''. Le seul moyen de savoir si un processeur fait de l'exécution dans le désordre est de mesurer les latences/durées de chaque instruction. Mais aucun état observable au-delà des ''timings'' n'est modifié. Pas avec la désambiguïsation mémoire. ===La consistance mémoire=== Le fait que la désambiguïsation mémoire ait des effets observables ne pose aucun problème avec un seul processeur, ca elle est conçue pour. Par contre, les techniques de désambiguïsation mémoire peuvent poser des problèmes avec plusieurs processeurs. Il arrive que des programmes partagent une même portion de mémoire, et lisent/écrivent dedans. Avec plusieurs processeurs, chaque processeur effectue la désambiguïsation mémoire dans son coin sans se préoccuper des autres. Les opérations d'écriture peuvent être mises dans le désordre du point de vue des autres processeurs, et les lectures effectuées par les autres processeurs peuvent alors renvoyer de vielles données. Un exemple classique est celui de deux cœurs qui partagent le même cache ou la même mémoire, mais qui utilisent chacun une file d'écriture (''store buffer''). Imaginons que le premier cœur effectue une écriture, puis une lecture, à des adresses différentes. L'écriture est retardée grâce au tampon d'écriture, alors que la lecture lit directement le cache. Pour le premier processeur, l'écriture s'est réalisée avant la lecture, car pour lui, la fin de l'écriture signifie écrire dans le tampon d'écriture. Mais pour le second cœur, l'ordre s'est inversé : il voit la lecture dans le cache, puis l'écriture dans le cache. La raison est qu'il n'a pas connaissance du contenu du tampon d'écriture de l'autre cœur. Un autre exemple : les techniques de prédiction d'adresse et de valeur posent des problèmes avec les dépendances RAW. Elles permettent à une instruction mémoire de s'exécuter avant une autre, malgré la présence d'une dépendance RAW entre les deux. L'ordre entre les deux instructions change, alors que les autres processeurs ne devraient pas le voir. Et dans les faits, les processeurs qui incorporent ces techniques ont des modèles de consistance mémoire qui ne permettent pas ces optimisations en cas de dépendances RAW. Les deux techniques doivent idéalement être désactivées pour les instructions mémoire qui ont des dépendances RAW. Les programmeurs doivent tenir compte de ce genre de situations, dans des cas assez rares et liés à la programmation bas niveau. Pour cela, ils doivent savoir comment le processeur réorganise les accès mémoire. Concrètement, chaque processeur définit un '''modèle de consistance mémoire''' qui indique quelles optimisations sont activées et non. Il s'agit d'une sorte de contrat entre le logiciel et le matériel : les programmeurs savent comment le processeur va réorganiser les lectures et écritures et peuvent coder leurs programmes en fonction. L'implémentation du système de consistance mémoire dépend grandement de l'architecture, mais des modèles ont été standardisés afin de permettre aux programmeurs de savoir où placer les barrières mémoires. Il s'agit de vrais standards, formalisés, parfois mathématiquement, que des chercheurs étudient sur un plan mathématique et algorithmique. Le modèle préféré des programmeurs est la '''consistance séquentielle''', où les différents processeurs effectuent des accès mémoire dans l'ordre du programme et les accès mémoire simultanés sont interdits. La première contrainte interdit l'usage de mécanismes de désambiguïsation mémoire, la seconde interdit l'usage de caches non-bloquants. La consistance séquentielle a donc un cout en performance assez important, ce qui fait que les processeurs modernes n'utilisent pas la consistance séquentielle. Un modèle plus flexible est le '''''total store ordering''''', qui autorise la présence d'une file d'écriture et l'usage de caches non-bloquants pour les lectures (interdit d'avoir plusieurs écritures simultanées). C'est plus ou moins celui utilisé par les processeurs x86, comme on le verra plus bas. Il existe des modèles de consistance mémoire plus relâchés, appelés des '''modèles de consistance faible''', qui autorisent diverses optimisations de spéculation mémoire, la combinaison d'écriture (fusionner deux écritures à la même adresse en une seule), etc. Ils sont supportés sur les architectures ARM et POWER. ===Les barrières mémoire=== Le changement d'ordre des lectures et écritures peut poser occasionnellement des problèmes avec la programmation pour les architectures multi-processeur ou multicœurs. Il existe des morceaux de code qui ne marchent que si la consistance séquentielle est respectée et il faut faire en sorte qu'ils marchent sur des processeurs avec un modèle de consistance mémoire différent. Pour cela, les processeurs incorporent des instructions appelées '''barrières mémoires''' ou '''''Fences'''''. Elles forcent le processeur à terminer tous les accès mémoire qui précédent dans l'ordre du programme. Certains processeurs possèdent une barrière mémoire pour les lectures et une autre pour les écritures. D'autres processeurs ajoutent des barrières mémoires dédiées à des cas particuliers. Elles permettent de forcer les accès à des données partagées à dérouler correctement. [[File:Fences.png|centre|vignette|upright=2|Fences.]] Leur implémentation est assez simple : elles empêchent l'émission de toute nouvelle instruction mémoire, tant qu'une condition précise n'est pas réunie. Par exemple, la barrière mémoire pour les écritures attend que la file d'écriture soit vidée avant de démarrer le moindre accès mémoire. Un compilateur peut placer les barrières mémoires au bon endroit, avec un peu d'aide du programmeur. Certains langages de programmation permettent d'indiquer au compilateur qu'une donnée doit toujours être lue depuis la mémoire RAM, via le mot-clé volatile. C'est très utile pour préciser que cette donnée est potentiellement partagée par plusieurs processeurs ou manipulable par des périphériques. Les compilateurs peuvent placer des barrières mémoires lors des lectures ou écritures sur ces variables, mais ce n'est pas une obligation. Il arrive aussi que le programmeur doive manipuler explicitement des barrières mémoires. Utiliser l'assembleur est alors une possibilité, mais qui est rarement exploitée, pour des raisons de portabilité. Pour limiter la casse, certains systèmes d'exploitations ou compilateurs peuvent aussi fournir des barrières mémoires explicites, encapsulées dans des bibliothèques ou cachées dans certaines fonctions. ===La modèle de consistance des processeurs x86=== Après avoir vu la théorie, passons maintenant à la pratique. Dans cette partie, on va voir les modèles de consistances utilisés sur les processeurs x86, ceux qu'on retrouve dans les PC actuels. Le modèle de consistance des processeurs x86 a varié au cours de l'existence de l'architecture : un vulgaire 486DX n'a pas le même modèle de consistance qu'un Core 2 duo, par exemple. Ils ont évolués au cours du temps, avec l'arrivée de nouvelles optimisations. De plus, leur formalisation a été progressive, et s'est faite au fur et à mesure. En général, les modèles de consistance des processeurs x86 ont toujours étés assez restrictifs, si on les compare aux autres processeurs. Il s'agit de modèles de type ''total store ordering'' (TSO), au moins dans les grandes lignes. Ils permettent donc d'effectuer certaines lectures en avance, soit parce que les lectures se font à des adresses différentes, soit par utilisation de réacheminement écriture vers lecture. Les possibilités d'exécution en avance des lectures varient suivant le processeur, elles deviennent de plus en plus performantes avec le temps. Le premier modèle de consistance est apparu sur les premiers processeurs x86 et est resté en place sur tous les processeurs de marque Pentium. Les processeurs de l'époque n'incorporaient pas beaucoup d'optimisations, le budget en transistor n'était pas assez conséquent pour ça, la mémoire n'était pas assez lente. Le modèle TSO de l'époque n'autorisaient que peu de lectures anticipées. Une lecture ne peut être exécutée en avance que sous les conditions suivantes : * les écritures contournées se font dans la mémoire cache ; * la lecture doit se faire dans la mémoire RAM ; * les écritures se font à une adresse différente de la lecture ; * aucune transaction avec un périphérique ne doit être en cours. A partir du Pentium 4, les choses changent. Le Pentium 4 est en effet le premier processeur à implémenter des techniques permettant d’exécuter plusieurs processus en parallèle, dont l'Hyperthreading. En conséquence, le modèle de consistance a dû être assoupli pour éviter de perdre bêtement en performance. Il s'agit d'un vrai modèle de type TSO, la majeure partie des contraintes sur les lectures anticipées sont réduites au minimum. Il y a aussi des contraintes supplémentaires pour les instructions dites atomiques, ainsi que pour les instructions qui accèdent aux périphériques ou aux entrées-sorties. Une optimisation séparée du TSO est que les écritures en mémoire peuvent changer d'ordre dans un cas exceptionnel : les écritures effectuées par les instructions de copie mémoire comme REPMOVSD, REPSCACB, ainsi que les instructions SSE MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, MOVNTPD. Les écritures effectuées dans ces instructions peuvent se faire dans un désordre complet (ou presque). Lors du passage au 64 bits, le modèle mémoire des processeurs x86 de l'époque a été formalisé dans un papier d'Intel appelé [https://www.cs.cmu.edu/~410-f10/doc/Intel_Reordering_318147.pdf Intel® 64 Architecture Memory Ordering White Paper], mis à jour en 2008 suite à quelques problèmes techniques. La formalisation du modèle a été d'une grande aide aux programmeurs, qui devaient se débrouiller avec des documentations qui ne formalisaient pas de modèle de consistance précis. Le document décrit un modèle de consistance mémoire portant le nom barbare de ''total lock order + causal consistency” (TLO+CC)'', qui admet plus d'optimisations que le modèle TSO. Mais la description était en réalité légèrement différente du modèle mémoire réellement implémenté sur les processeurs existants, qui utilisaient du TSO, sauf en de rares occasions. Les concepteurs de processeur font souvent cela, à savoir définir un modèle de consistance plus laxiste que réellement implémenté, pour des questions de comptabilité. Ainsi, si les futurs processeurs de la marque implémentent un modèle plus laxiste, les programmes déjà codés continuent de fonctionner. Le modèle décrit dans la documentation laisse de la marge pour le futur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le parallélisme mémoire | nextText=Le parallélisme mémoire }} </noinclude> iic0fn7hckc8ipwsn34tyfmq3vqh1qb 774132 774129 2026-10-04T13:43:49Z Mewtow 31375 /* L’émission dans l'ordre des accès mémoire */ 774132 wikitext text/x-wiki Dans les chapitres précédents, nous avons vu la mal-nommée exécution dans le désordre dans les chapitres précédents, qui devrait plutôt s'appeler l'émission dans le désordre. Elle modifie l'ordre des instructions pour gagner en efficacité, mais cela n'est valable que pour les instructions travaillant sur des registres ! Nous n'avons pas parlé de l'exécution dans le désordre des accès mémoire, car ils sont un peu à part. Et pour comprendre pourquoi, nous allons dédier ce chapitre à la gestion des accès mémoire dans le pipeline. Avant de poursuivre, faisons un rappel rapide sur les dépendances de données. Il faut distinguer deux types de dépendances de données : les dépendances de registres (deux instructions manipulent le même registre), et les dépendances d'adresse (deux instructions manipulent la même adresse). Deux instructions ont une dépendance d'adresse si elles écrivent ou lisent la même adresse mémoire. Des instructions mémoires qui lisent/écrivent des adresses différentes sont indépendantes. Lorsque deux instructions mémoire lisent/écrivent à la même adresse, on parle d''''aliasing mémoire'''. Formellement, l'aliasing mémoire n'est ni plus moins que l'application des dépendances de données aux instructions mémoire. Et comme les autres dépendances de données, il existe plusieurs types d'aliasing mémoire : RAR, RAW, WAR et WAW. Les dépendances RAR ne posent aucun problème, les lectures peuvent changer d'ordre sans aucun problème, tant qu'il n'y a pas d'écritures entre les deux. Par contre, les autres dépendances imposent trois contraintes. * Les dépendances RAW imposent que les lectures ne doivent pas s'exécuter avant une écriture à la même adresse. * Les dépendances WAR imposent que l'on ne peut pas déplacer une écriture avant une lecture à la même adresse. * Enfin, les dépendances WAW font que l'ordre des écritures doit être celui du programme : impossible de changer l'ordre de deux écritures. Les trois contraintes peuvent s'implémenter de plusieurs manières différentes. Comme pour les dépendances de données normales, seules les dépendances RAW sont de vraies dépendances de données. Les deux autres dépendances viennent du fait que l'on utilise la même adresse pour stocker des valeurs différentes. Détecter les cas d'''aliasing'' mémoire est primordial pour gérer la désambiguïsation mémoire, et cela demande de comparer des adresses. S'il existe des techniques similaires au renommage de registre permettent de les faire disparaitre, elles sont très limitées. Diverses optimisations permettent d'émettre des accès mémoire dans le désordre, exactement comme l’exécution dans le désordre le fait pour les autres instructions. Elles utilisent pour cela des ''schedulers'' spécialisées dans les accès mémoire. Il faut vraiment insister sur le fait que l'exécution dans le désordre proprement dite ne se préoccupe pas des micro-opérations mémoire. Pour faire la distinction, on parle de '''désambiguïsation mémoire''' (''memory disambiguation'') pour parler de l'exécution dans le désordre des accès mémoire. Le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. La raison est que la donnée lue est utilisée par d'autres instructions, dépendantes de la lecture. Elles sont donc à la tête d'une chaine de dépendances de données qui peut bloquer le pipeline si elle n'est pas résolue très tôt. Dès qu'une lecture peut être lancée, elle accède directement au cache de données. Par contre, si l'''aliasing'' ne le permet pas (écriture à la même adresse pas encore effectuée) ou que l'adresse à lire n'a pas encore été calculée, la lecture attend son tour. Le problème est que la désambiguïsation mémoire demande de comparer des adresses, pour détecter les dépendances d'adresse. Et cela pose de sérieuses contraintes pour son implémentation. L'exécution dans le désordre normale compare des registres, ce qui est beaucoup plus simple. Les registres utilisés sont directement encodés dans l'instruction elle-même, ce qui fait qu'on peut détecter les dépendances de registre après le décodage. Et elles peuvent même être éliminées par le renommage de registre pour les dépendances WAW et WAR. Par contre, les adresses ne sont pas encodées dans l'instruction elle-même (sauf avec le cas particulier de l'adressage absolu), mais sont lues depuis les registres ou calculées par une unité de calcul. Les adresses ne sont donc connues qu'une fois que l'instruction a attendu suffisamment de temps dans le ''scheduler''. ==L’émission dans l'ordre des accès mémoire== Avant toute chose, faisons quelques rappels sur l'exécution dans le désordre "normale", sans désambiguïsation mémoire. Les micro-opérations renommées sont mises en attente dans un ''scheduler'', le temps que leurs opérandes soient disponibles. Les µops mémoire font cependant exception. ===La file de µops mémoire=== Avec l''''émission dans l'ordre des accès mémoire''', le processeur émet les accès mémoire dans l'ordre du programme. Cela implique que l'unité mémoire n'a pas de ''scheduler'', ni de station de réservation associée. À la place, les micro-opérations mémoire sont placées dans une file de micro-opération dédiée, appelée la '''file de µops mémoire'''. La file de µops mémoire est une mémoire FIFO, dont chaque entrée peut accueillir une µops mémoire. [[File:Unité de gestion des accès mémoires dans l’ordre.png|centre|vignette|upright=2.0|Unité de gestion des accès mémoires dans l’ordre]] Par contre, le processeur utilise des ''schedulers'' pour les unités de calcul. Autant les accès mémoire se font dans l'ordre, autant les instructions arithmétiques/logiques/branchements/autres sont exécutées dans le désordre. En clair, le processeur supporte l'exécution dans le désordre, sauf pour les micro-opérations mémoire. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] Vu de l'extérieur, il faut attendre qu'un accès mémoire soit terminé pour lancer le suivant. Avant d'émettre une micro-opération mémoire, la file de micro-opération (son ''scoreboard'') vérifie si l'unité d'accès mémoire est libre ou non. Si elle est occupée, un accès mémoire est en cours, et on ne peut pas émettre de micro-opération mémoire. Une fois l'accès mémoire terminé, la mémoire ou le cache envoient un signal pour indiquer que l'accès est fini, et c'est au tour d'une nouvelle micro-opération mémoire. L'unité mémoire a un port sur lequel on envoie la micro-opération émise, que ce soit une écriture ou une lecture. Une file de µops implique la ''lecture après émission'', à savoir que les µops mémoire quittent la file de µops, avant de lire les registres adéquats. Il y a donc une connexion entre banc de registre et unité mémoire, comme sur n'importe quel autre processeur. Mais la file de µops n'est pas connectée au banc de registre, ni au réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] L'organisation précédente laisse penser que les calculs d'adresse sont forcément réalisés dans l'unité mémoire. Mais ce n'est pas une obligation. Il est parfaitement possible de scinder une µops mémoire en deux : une µop mémoire proprement dite et une µop de calcul d'adresse. La liaison entre les deux doit cependant se faire en utilisant un registre. L'adresse calculée est enregistrée dans un registre d'adresse "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. ===La file d'écriture : une FIFO de mise en attente des écritures=== L'unité mémoire exécute une µops mémoire à la fois. Avec un détail particulier : les écritures doivent attendre que toutes les instructions se terminent, avant de pouvoir s'exécuter. Sans ça, les exceptions précises sont impossibles, la prédiction de branchement l'est aussi et l'exécution dans le désordre aurait des conséquences fâcheuses (en exécutant des écritures en avance). Précisément, les écritures doivent respecter deux contraintes : s'exécuter dans le tout dernier étage du pipeline, s'exécuter dans l'ordre du programme. Il serait possible de bloquer l'émission des écritures tant que le ROB ne donne pas son accord. Mais ce n'est pas du tout ce qui est fait. A la place, les écritures sont mises en attente dans une mémoire FIFO, appelée la '''file d'écriture''', ou encore la ''Write Queue''. Les écritures sont mises en attente, jusqu'à ce que le ROB leur donne le okay pour transfer l'écriture en mémoire RAM. Nous en avions déjà parlé dans le chapitre sur le pipeline, dans la section sur les exceptions précises. Mais quelques rappels ne feront pas de mal. L'écriture est insérée dans la file d'écriture lorsqu'elle est émise, elle en sort à l'étage de ''Writeback'' si tout se passe bien. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente, faites à tort. De plus, la file d'écriture est une mémoire FIFO, qui mémorise les écritures dans l'ordre du programme. En effet, les écritures sont ajoutées dans la file d'écriture lorsqu'elles sont émises, et elles sont émises dans l'ordre du programme. [[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Write Queue sur un pipeline fixe]] La file d'écriture garantit que les écritures sont dans l'ordre du programme. Et cette propriété élimine totalement les dépendances WAW. Le fait que les écritures se font à la fin du pipeline élimine quant à elle les dépendances WAR. Il ne reste plus qu'à gérer les dépendances RAW, celles où une lecture lit une donnée écrite par une autre instruction. Une telle situation survient, car les écritures sont mises en attente pendant un certain temps, la donnée écrite peut être lue pendant ce laps de temps. La lecture ne peut pas lire la donnée dans le cache, car la donnée à lire n'y est pas encore enregistrée. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire, mais il existe. Il se manifeste surtout sur les processeurs avec peu de registre, où des données doivent régulièrement être déplacées temporairement en mémoire RAM. Pour éviter cela, une solution consiste mettre en attente les lectures si une écriture est en attente. Les lectures sont maintenues dans la file de µops tant que la file d'écriture n'est pas vide. La file de µops mémoire est suivie d'un circuit de détection des dépendances, un '''''scoreboard'' mémoire''' qui est assez simple. L'unité mémoire fournit un bit qui indique si la file d'écriture est vide ou non, ainsi qu'un second bit qui indique qu'elle est vide. Les deux ne sont pas compliqués à générer, ils font partie intégrante de l'interface de toute mémoire FIFO digne de ce nom. Le ''scoreboard'' mémoire utilise ces deux bits pour savoir s'il peut émettre une micro-opération mémoire. Il n'émet pas de lecture tant que la file d'écriture n'est pas vide. Il peut par contre émettre des écritures tant qu'elle n'est pas pleine. Une meilleure solution serait d'interdire les lectures, mais seulement en cas de dépendance mémoire détectée. Et c'est là qu'intervient la technique du '''''load bypassing'''''. Elle autorise une lecture, si elle n'a aucune dépendance avec les écritures mises en attente dans la file d'écriture. Elle doit être mise en attente dans le cas contraire. Pour l'implémenter, il faut comparer l'adresse à lire avec toutes les adresses dans la file d'écriture. La lecture est exécutée immédiatement si aucune correspondance n'est trouvée, mais elle est mise en attente dans le cas contraire. Pour cela, la file d'écriture est modifiée et devient un mélange entre FIFO et mémoire associative. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'' : la lecture doit être bloquée. Sinon, c'est un défaut de ''Write Queue'', la lecture accède au cache, là où se trouve la donnée à lire. Dans le chapitre sur le contournement, nous avons parlé d'une forme de contournement qui corrige ce problème. Les lectures peuvent lire la donnée depuis la file d'écriture si besoin, ce qui renvoie la donnée adéquate. Pour cela, les lectures consultent à la fois le cache et la file d'écriture, comme dit plus haut. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'', ou encore de '''réacheminement écriture-vers-lecture'''. [[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]] La lecture est mise en attente dans l'unité mémoire, vu que c'est elle qui fait les comparaisons d'adresse. Du point de vue extérieur, le ''scoreboard'' mémoire ne voit pas s’il y a eu succès dans la file d'écriture ou non. Il voit juste que l'unité mémoire est occupée et ne peut pas accepter de nouvelle lecture. Elle reste occupée durant quelques cycles, en cas de défaut de ''Write Queue'', le temps de faire la lecture dans le cache. Par contre, en cas de succès de ''Write Queue'', elle reste occupée tant que la lecture n'a pas donné de résultat, ce qui inclue le temps de mise en attente. Les processeurs haute performance implémentent tous le ''Store to load Forwarding'', mais quelques processeurs basse performance ne le font pas. Ils se contentent du ''load bypassing'', qui offre des performances correctes pour un cout en matériel bien plus modeste. Pour donner un exemple, la micro-architecture Jaguar d'AMD était dans ce cas. Elle a été utilisée sur les consoles de jeu de 8ème génération, comme la Xbox One et la Playstation 4. Les processeurs haute performance préférent utiliser plus de circuits pour implémenter le ''Store to load Forwarding'', mais l'implémentation n'est souvent pas complète. En effet, l'implémentation est souvent simple et ne prend en compte que des lectures/écritures à des adresses identiques, sans compter que la taille des données doit aussi être identique. Une écriture d'un entier de 32 bit sera contournée et envoyée à une lecture de 32 bits à la même adresse. Mais la lecture lit seulement 16 bits sur les 32 écrits, le contournement peut ne pas marcher. Concrètement, ce cas précis fonctionnera. Mais le cas inverse, avec une lecture lisant plusieurs écritures de taille plus petite, ne marchera pas sur tous les processeurs. Ne parlons pas du cas où les données écrites sont à cheval sur deux lignes de cache. ==L'exécution anticipée des calculs d'adresse== La désambiguïsation mémoire fonctionne d'autant mieux que les adresses à lire/écrire sont connues le plus tôt possible. Pour cela, il est possible de séparer les µops mémoire en deux micro-instructions : une pour le calcul d'adresse et l'autre pour les accès mémoire. Cela permet de calculer les adresses dès que possibles, et donc de vérifier à l'avance si l'adresse calculée a des dépendances avec celles des autres instructions. La détection des dépendances est ainsi anticipée de quelques cycles, permettant un accès anticipé sûr des accès mémoire. On parle de '''calcul anticipé des dépendances'''. ===La ''Load/Store Queue'' : une file de µops améliorée=== Implémenter cette technique demande de faire deux choses. Premièrement, il faut faire les calculs d'adresse en-dehors de l'unité mémoire, soit dans une ALU entière, soit dans une unité de calcul d'adresse dédiée. Deuxièmement, les adresses calculées doivent être mises en attente quelque part, et être liées facilement à la µop mémoire associée. Pour le second point, la file de µops mémoire parait être un bon candidat, mais n'est pas exactement la solution. Une file de µops au sens strict ne mémorise pas les opérandes des µops, donc pas d'adresse. Cela implique la lecture après émission, ce qui veut dire que l'adresse à lire/écrire est lue depuis les registres une fois que la µops sort de la file de µops (idem pour la donnée à écrire). En pratique, cette implémentation rend difficile de faire les calculs d'adresse ailleurs que dans l'unité mémoire. Heureusement, il est possible d'adapter la file de µops pour gérer la lecture avant émission. L'idée est qu'elle mémorise, pour chaque µop mémoire, l'adresse à lire/écrire et d'autres informations. Il ne s'agit donc plus vraiment d'une file de µops mémoire mais quelque chose d'autre, une sorte d'intermédiaire entre une station de réservation et une file de µops. D'un côté, elle fonctionne comme une file de µops car elle émet les µops dans l'ordre du programme. De l'autre, elle mémorise les opérandes des µops mémoire, ce qui en fait une pseudo-station de réservation. Une telle structure est appelée une '''''Load/Store Queue''''' (LSQ). Une LSQ contient plusieurs entrées, chaque entrée pouvant accueillir une µops mémoire et ses opérandes. Une entrée contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures. Les deux derniers champs sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. Elle contient aussi des bits de validité, qui indique si l'adresse ou la donnée sont disponibles, ou si le champ associé est vide. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2|Load Store Queue unifiée]] La LSQ doit faire le lien entre une adresse calculée par l'ALU entière, et l'entrée associée de la LSQ. Pour cela, l'unité d'émission peut associer un '''identifiant LSQ''' à une µops mémoire. L'identifiant est généré à l'émission. Aussi bien la µops de calcul d'adresse que a µop mémoire ont toutes deux le même identifiant. L'identifiant est propagé avec la µop de calcul d'adresse, comme tout signal de commande. En sortant de l'AGU, l’identifiant est transmis à la LSQ en même temps que l'adresse. La LSQ regarde quelle entrée a cet identifiant, et mémorise l'adresse dedans. La gestion des données à écrire se fait elle avec le système de contournement normal. La LSQ sait dans quel registre se trouve la donnée à écrire, elle surveille juste le système de contournement pour détecter quand la donnée est disponible. ===Le contournement des adresses mémoire avec une LSQ=== Avec une LSQ, les registres sont lus quand la µop mémoire est insérée dans la LSQ, si elles sont disponibles. Si elles ne sont pas encore disponibles, elles seront envoyées par le système de contournement plus tard. Cela implique qu'il y ait un système de contournement, qui relie les ALUs entières à la LSQ. Les AGUs envoient leur résultat à la LSQ, qui place alors l'adresse calculée dans la bonne entrée, la bonne "µop". [[File:Load-Store Queue.png|centre|vignette|upright=2|Load-Store Queue]] Une autre solution utilise des ALU spécialisées dans les calculs d'adresse, appelées des ''Adress Generation Unit'', abrévié AGU. L'avantage est que cela simplifie grandement le réseau de contournement : seules les AGU sont connectées à la LSQ. Un autre avantage est lié à l'adressage de la destination. En utilisant des opérations entières usuelles, il faudrait préciser que le résultat est à destination de la LSQ, pour configurer le réseau de contournement. Avec des AGU, les calculs d'adresse sont des micro-opérations différentes des additions et soustraction, elles sont envoyées au AGU spécifiquement, pas besoin de préciser la destination. [[File:ALU et LSQ.png|centre|vignette|upright=2|AGUs et Load-Store Queue.]] Dans les deux cas, les µops mémoire sont alors considérées comme des opérations entières un peu spéciales, gérées par le système d'exécution dans le désordre. Elles peuvent être émises dans le désordre, mais la LSQ se chargera de faire les accès mémoire dans l'ordre. Il y a donc calcul des adresses dans le désordre, mais accès mémoire dans l'ordre. ==Les ''schedulers'' mémoire unifiés== Avec la technique précédente, les µops mémoire s'exécutent dans l'ordre, à savoir que l'on ne peut pas déplacer une lecture avant ou après une autre. Il arrive que des lectures prêtes soient mises en attente, parce qu'une lecture précédente attend ses opérandes. Et ce retard sur les lectures se répercute sur les instructions lecture-dépendantes, donc sur le reste du pipeline.Les conséquences sur les performances sont assez importantes. Mais avec l'implémentation qui va suivre, il est possible exécuter les lectures dans le désordre tant que les conditions d'''aliasing'' sont respectées. L'implémentation remplace les files de µops mémoire par un ''scheduler'' spécialisé dans les µops mémoire. Il sera appelé le '''''scheduler'' mémoire''' dans ce qui suit. [[File:Processeur avec émission dans le désordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans le désordre des accès mémoire]] Une entrée du ''scheduler'' mémoire contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures (inutile pour les écritures). Elle contient aussi des bits de validité pour chaque champ qui indiquent si l'adresse d'accès est connue, ou si la donnée à écrire est disponible pour une écriture. Les champs pour la donnée à écrire ou le registre de destination sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2.5|''Load/Store Queue''.]] Lorsqu'une micro-opération mémoire est émise, elle est insérée dans le ''scheduler'' mémoire. Si l'adresse à lire/écrire est disponible, elle est immédiatement lue depuis les registres, idem pour une éventuelle donnée à écrire. Sinon, la micro-opération attend dans le ''scheduler'' mémoire que son adresse soit calculée par le processeur. ===Les calculs d'adresse avec un ''scheduler'' mémoire=== Le ''scheduler'' mémoire détecte des dépendances d'adresse entre instructions. Pour cela, il compare toutes les adresses à lire/écrire. Il mémorise donc les dites adresses, ce qui en fait une station de réservation. Et cela implique que ces adresses ont été calculées ailleurs, dans une ALU entière, ou dans une AGU séparée de l'unité mémoire. L'implémentation la plus simple effectue les calculs d'adresse dans les ALU entières. La sortie de toutes les ALU entière est reliée au ''scheduler'' mémoire. [[File:Contournement pour la file de µops mémoire.png|centre|vignette|upright=2|''scheduler'' mémoire]] Une autre implémentation utilise des unités de calcul d'adresse dédiées, afin de simplifier le réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse via AGU.png|centre|vignette|upright=2|ALU et ''scheduler'' mémoire]] Une conséquence est que l'implémentation impose que toutes les instructions mémoire sont découpées en deux µops : une µop pour calculer l'adresse, et une µop mémoire. C'est un défaut assez important, car une instruction mémoire prendra deux places dans le ROB, dans les stations de réservation, etc. Vu que les instructions mémoire sont très fréquentes, la capacité du ROB/autres est alors fortement réduite. Il faut noter que les adresses calculées dans l'ALU/AGU doivent atterrir dans l'entrée adéquate, celle de la µops qui doit recevoir l'adresse. La seule solution est que le ''program counter'' de la µops est transmis dans le pipeline, propagé avec la µops de calcul d'adresse. Quand le résultat est calculé, le ''program counter'' associé est envoyé avec au ''scheduler'' mémoire. Le ''scheduler'' mémoire enregistre alors l'adresse dans l'entrée qui est taguée avec cette adresse. ===La détection des dépendances mémoire=== Le ''scheduler'' mémoire intègre un système de détection des dépendances, qui est bien différent de celui d'une station de réservation normale. Les ''schedulers'' mémoire comparent non pas des noms de registres, mais des adresses/opérandes. Pour détecter les dépendances, le ''scheduler'' mémoire compare les adresses à lire/écrire. Pour les µops dont l'adresse est inconnue, le cas dépend de l'implémentation. La version la plus conservatrice suppose qu'elle a des dépendances possibles avec toutes les autres. Chaque micro-opération en attente compare son adresse avec celle de toutes les micro-opérations précédentes. Ou presque, la comparaison ne doit tenir compte que des écritures, et de celles situées avant dans l'ordre du programme. Le ''scheduler'' mémoire doit donc mémoriser les instructions dans l'ordre du programme, tout en faisant des comparaisons d'adresse. Ça en fait une sorte hybride entre mémoire associative et mémoire FIFO. Nous avions vu dans le chapitre "L'exécution dans le désordre" qu'il y a plusieurs manières pour implémenter une station de réservation : avec une mémoire associative, avec préplanification, avec des matrices de dépendances. Le problème est qu'on ne peut pas utiliser une mémoire associative pour le ''scheduler'' mémoire, qui utilise des comparateurs pour comparer les noms de registres/adresses. En effet, la détection des dépendances mémoire demande de faire beaucoup de comparaisons : chaque adresse est comparé à toutes les autres. Et les adresses faisant facilement 32 à 64 bits, les comparateurs sont bien plus imposants que ceux qui comparent des noms de registres de 5-10 bits. Le cout en matériel serait trop élevé, de même que le temps de calcul des dépendances. À la place, on utilise des matrices de dépendances. La détection des dépendances mémoire utilise précisément deux matrices de ce type : une matrice de disponibilité, et une matrice de dépendance. Ces matrices forment une espèce de tableau carré, organisé en lignes et en colonnes. L'instruction dans la énième entrée se voit attribuer la énième ligne et la énième colonne. À l'intersection d'une ligne et d'une colonne, on trouve un bit qui indique si l'instruction de la ligne et celle de la colonne ont une dépendance. La '''matrice de disponibilité''' permet de bloquer l'exécution des lectures/écritures tant qu'une instruction précédente n'a pas calculé son adresse, tant qu'on ne peut pas vérifier les dépendances. Lorsqu'une instruction est ajoutée dans le ''scheduler'' mémoire, elle met tous les bits de la colonne attribuée à 1 si l'adresse à lire/écrire est inconnue, puis les met à 0 une fois l'adresse calculée. Une instruction ne peut pas démarrer si, sur la ligne qui lui est attribuée, se trouve un 1. Plus précisément, si on trouve un 1 pour les instructions plus anciennes, ce qui fait qu'une partie de la matrice est ignorée. Les bits notés X dans le tableau suivant sont ignorés, car ils précisent la disponibilité des opérandes d'un opérande ultérieure, ce qui est inutile. En clair, les calculs d'adresse modifient les colonnes, et on vérifie les lignes pour autoriser l'exécution. {|class="wikitable" |- ! Instructions mémoire dans la LSB, triées de la plus ancienne à la plus jeune ! 0 !! 1 !! 2 !! 3 !! 4 !! 5 !! 6 |- ! 0 | 1 || X || X || X || X || X || X |- ! 1 | 0 || 0 || X || X || X || X || X |- ! 2 | 0 || 1 || 0 || X || X || X || X |- ! 3 | 0 || 1 || 1 || 1 || X || X || X |- ! 4 | 0 || 0 || 0 || 0 || 1 || X || X |- ! 5 | 0 || 0 || 1 || 0 || 0 || 1 || X |- ! 6 | 1 || 1 || 1 || 1 || 1 || 1 || 0 |} Les '''matrices de dépendances''' fonctionnent sur le même principe, mais déterminent les dépendances mémoire. Lorsque le processeur a calculé une adresse, il la compare avec les adresses à lire/écrire déjà dans le ''scheduler'' mémoire. Il met alors à jour les bits de la colonne de la matrice de dépendance : le bit à l'intersection d'une ligne et d'une colonne est mis à 0 si les deux instructions n'ont pas de dépendances, à 1 si elles en ont une. Une instruction mémoire est exécutée si tous les bits de sa ligne sont à 0. Là encore, on ne tient compte que des bits pour les instructions plus anciennes, la matrice a encore une fois une forme triangulaire, comme pour la matrice de disponibilité. Une optimisation scinde le ''scheduler'' mémoire en deux structures, aux rôles légèrement différents. La première gère la disponibilité des opérandes, la seconde gère seulement les dépendances mémoire. La première est une station de réservation où les instructions sont mises en attente tant que leurs opérandes ne sont pas prêts. La seconde met en attente des micro-opérations dont les opérandes sont prêts, mais qui doivent attendre pour une autre raison : dépendance mémoire, cache occupé, etc. L'avantage est qu'on remplace une grosse station de réservation par deux plus petites, mais l'intérêt est surtout relié au système de détection des dépendances. Sans cette optimisation, on a deux matrices énormes reliées à une ''Load/Store Queue'' unique. Avec la séparation, la matrice de disponibilité est reliée uniquement à la première station de réservation, alors que la matrice de dépendance est reliée uniquement à l'autre. Les deux matrices sont donc plus petites, car reliées à deux stations de réservation mémoire plus petites. ==L'exécution spéculative des accès mémoires== Pour rappel, le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. Les données lues sont en effet, le départ d'une série d'instructions dépendantes, généralement des instructions arithmétiques, mais parfois d'autres lectures. Quand on manipule des structures de données, il n'est pas rare qu'une lecture lise un pointeur-adresse utilisé par une autre lecture. Les techniques de désambiguïsation mémoire que nous avons vu précédemment sont dites conservatrices, à savoir qu'elles ne changent pas l'ordre de deux instructions mémoire si elles ont une dépendance d'adresse. Elles exécutent une lecture le plus tôt possible, mais seulement si on sait que celle-ci n'aliase pas d'écritures précédentes. Et cela demande de connaitre les adresses des écritures précédentes dans l'ordre du programme. Si une lecture est précédée par une ou plusieurs écritures, les adresses à écrire doivent être connues. Il se peut en effet qu’une écriture dont on ne connait pas l'adresse aliase la lecture. La garantie est liée à la présence de la file de µops mémoire ou la file de µops STORE. Si une seule écriture a une adresse inconnue, elle reste dans la file de µops mémoire et bloque l'émission de toutes les autres micro-opérations mémoire, lecture comme écriture. Mais il existe des techniques dites spéculatives, qui modifient l'ordre des accès mémoire sans se soucier de leurs dépendances. Elles émettent les lectures dès que leurs opérandes sont prêts, sans se préoccuper de l'aliasing avec une écriture précédente. Pour éviter tout problème, le processeur vérifie si aucune dépendance mémoire n'est violée et corrige le tir dès qu'une violation est détectée. Le processeur vérifie s'il a fait une erreur de prédiction, en vérifiant les adresses à écrire ou lire. En cas d'erreur, le processeur fait comme lors d'une prédiction de branchement ratée : il remet le pipeline en ordre et ré-exécute les accès mémoire dans le bon ordre. Il s'agit d'un cas particulier d’'''exécution spéculative''', au même titre que la prédiction de branchement. Toutes les techniques de ce genre exécutent des lectures dans le désordre. L'avantage est que cela permet d'exécuter les lectures en avance. Vu que la donnée lue est l'opérande d'autres instructions, les exécuter en avance fait prendre de l'avance à un paquet d'instructions. Mais il faut comparer cet avantage au risque de mauvaise prédiction. Et aussi bizarre que cela puisse paraître, cela donne de bons résultats. Il faut dire que les situations où une lecture relit une donnée tout juste écrite sont rares : même pas 2 à 3 % des lectures. Les dépendances mémoire sont donc très rares, les cas d'''aliasing'' sont vraiment limités. En termes de cout en transistors, le hardware qui vérifie les dépendances mémoire reste globalement le même à peu de choses près, il est juste déplacé dans le pipeline et adapté à sa nouvelle fonction. ===La file de lectures=== La technique que nous allons voir émet les lectures dans l'ordre, ce qui simplifie énormément la gestion des dépendances. Les lectures sont émises dans l'ordre, les écritures aussi. Par contre, l'ordre entre lectures et écritures n'est pas respecté. Une lecture peut passer avant une écriture sans problèmes, voire avant plusieurs écritures d'un seul coup. Une telle lecture exécutée en avance d'une autre écriture est appelée une '''lecture spéculative'''. Une lecture spéculative peut donc sauter devant une ou plusieurs écritures, qui seront appelées des '''écritures sautées'''. Une écriture sautée est une précédant une lecture spéculative, dans l'ordre du programme. Évidemment, il se peut qu'une écriture sautée écrive la donnée qui est lue par la lecture spéculative. Il y a alors une violation de dépendance mémoire. Mais le processeur suppose qu'il n'y a pas de dépendantes de ce type et émet la lecture en avance. Rassurez-vous, il surveille quand même la situation, pour tout annuler ensuite s'il détecte une violation de dépendance mémoire. J'ai dit plus haut que le processeur émet les lectures dans l'ordre, ainsi que les écritures dans l'ordre. Pour cela, la LSQ est scindée en deux files de µops : une pour les µops LOAD et une autre pour les µops STORE. Les cours d'architecture des ordinateurs nomment souvent ces deux files en utilisant les termes de ''file de STORE'' (''store queue'') et de ''file de LOAD'' (''load queue''), mais il y a un risque de confusion avec la file d'écriture vue précédemment, et la file de lecture qu'on introduira dans la suite. Dans ce qui suit, nous parlerons de '''file de µops LOAD''' et de '''file de µops STORE''', pour éviter toute confusion. Les deux files sont chacune connectées à l'unité mémoire, via un port dédié. L'unité mémoire a donc un port pour les lectures, et un port pour les écritures. Le port d'écriture est relié à la file d'écriture, le port de lecture est directement relié au cache de données. Il faut noter qu'une unité mémoire à deux ports n'est pas si bizarre que cela. En effet, le cache de données a lui aussi deux ports, dans la majorité des cas, avec un port de lecture et un port d'écriture. [[File:Load Ordering, Store Ordering.jpg|centre|vignette|upright=2|Exécution dans le désordre des lectures.]] Maintenant, voyons comment sont détectées les violations de dépendances mémoire. Les dépendances qui peuvent être violées dans ce cas de figure se font entre une écriture sautée et une lecture spéculative. Si une lecture spéculative lit une donnée qui est écrite par une écriture sautée, alors une dépendance RAW a été violée. Pour les détecter, le processeur garde une trace des lectures spéculatives; tant que les instructions précédentes ne sont pas terminées. Pour cela, il utilise une file d'attente, qui mémorise les lecture spéculatives. Nous l'appellerons la ''file de vérification des lectures'', ou encore la '''file de lectures'''. Elle est une sorte d'équivalent de la file d'écriture, mais pour les lectures. Les lectures sortent de la file µops LOAD dès que leurs adresses sont calculées, sans tenir compte des dépendances mémoires. Elles sont alors ajoutées à la file de lectures et s'exécutent immédiatement, de manière spéculative. Pour vérifier qu'il n'y a pas d'erreur de spéculation, le processeur conserve l'adresse de la lecture effectuée dans la file de lectures. Les lectures quittent la file de lecture quand toutes les instructions précédentes dans l'ordre du programme sont terminées, à savoir tant quand elles ont quitté le tampon de réordonnancement/ROB. Notons que la file de vérification des lectures contient les lectures dans l'ordre du programme. En effet, les lectures sont accumulées en sortant de la file de µops LOAD, où elles sont triées dans l'ordre du programme. Et cela permet de gérer les erreurs de prédiction parfaitement, en annulant les instructions suivant une erreur de prédiction. [[File:Spéculation avec une file de lecture.jpg|centre|vignette|upright=2.5|Spéculation avec une file de lecture.]] Reste à détecter les erreurs de prédiction, ce qui peut être fait avec deux méthodes. La première détecte les violations de dépendances mémoire à chaque écriture. L'idée est que chaque écriture vérifie si une lecture anticipée n'a pas lue la donnée écrite en avance. Pour cela, quand une écriture est émise, elle consulte la file de lecture pour voir s'il y a un conflit d'adresse. La vérification fait cependant gaffe à ne prendre en compte que les lectures situées après l'écriture dans l'ordre du programme. Précisément, elle envoie l'adresse à écrire à la file de lecture et cherche si une lecture ultérieure a eu lieu à la même adresse. Si c'est le cas, cela signifie qu'une lecture anticipée aurait du lire la donnée actuellement écrite, mais que la lecture a été exécutée trop en avance. La lecture n'a pas lu la bonne donnée, il y a eu une violation de dépendance mémoire. Pour faire cette vérification, la file de lecture est un mélange entre FIFO et cache/mémoire associative, sur le même modèle que la file d'écriture. La différence est que la consultation de la file de lecture ne sert pas à faire du contournement, mais à détecter les dépendances. Quand une dépendance est détectée, l'écriture se fait normalement. Par contre, la lecture marquée comme invalide dans la file de lecture. L'exécution du programme se poursuit, jusqu'à ce que la lecture arrive à l'étape de ''commit'', à savoir que le ROB lui dit que les instructions précédentes sont terminées sans encombre. Là, la lecture étant invalide, le processeur déclenche une procédure de réparation, qui vide partiellement le pipeline de toutes les instructions qui suivent la lecture. La lecture est de plus ré-executée, afin de lire la bonne donnée. Avec la seconde méthode, les violations de dépendance mémoire sont détectées au moment où la lecture quitte à la file de lectures et le ROB. L'idée est de comparer la donnée lue de manière spéculative avec la donnée lue de manière non-spéculative. Pour faire la comparaison, la lecture spéculative copie la donnée lue dans la file de lecture. Quand la lecture quitte la file de lecture et le ROB, la lecture est ré-exécutée et la donnée relue. Le processeur compare alors la donnée lue à ce moment avec la donnée mémorisée dans la file de lecture. Si la donnée est différente, alors il y a eu erreur de violation de dépendance mémoire. Évidemment, relire une donnée n'est pas gratuit. Il est possible de limiter son impact en performance en dédiant un port de lecture sur le cache rien que pour ça. Mais le cout en circuit est comparable à l'usage d'une FIFO associative de la technique précédente. Mais quoi qu'il en soit, le cache doit permettre des lectures en un seul cycle, sans quoi la méthode ne marche pas vraiment. ===La prédiction de dépendances mémoires=== Certains processeurs essayent de prédire si deux accès mémoires sont dépendants : ils incorporent une unité qui va fonctionner comme une unité de prédiction de branchement, à la différence qu'elle prédit les dépendances mémoires. Si cette unité prédit que deux accès mémoires sont indépendants, le processeur les exécute dans le désordre, et les exécute dans l'ordre du programme dans le cas contraire. Il faut prendre en compte les erreurs de prédiction, ce qui est fait comme pour les mauvaises prédictions de branchement : on vide le pipeline. Beaucoup de techniques de prédiction des dépendances mémoire ont été inventées et en faire une revue exhaustive serait long et fastidieux. On pourrait citer les ensembles colorés (''color sets''), et bien d'autres techniques. Mais nous n'allons parler que des techniques les plus élémentaires. Quoi qu’il en soit, la recherche sur le sujet est assez riche, comme toujours quand il s'agit d'architecture des ordinateurs. On peut améliorer cette technique en mémorisant les instructions qui ont causé une mauvaise prédiction de dépendance mémoire. La prochaine fois qu'on exécute ces instructions, on sait qu'il y a de grandes chances qu'il se produise une erreur de prédiction, ce qui pousse à ne pas les exécuter de manière spéculative. On peut pour cela réutiliser les techniques de prédiction de branchement, tels des compteurs à saturations, mis à jour en cas d'erreurs de prédiction de dépendances mémoire. Dans tous les cas, on trouve un cache, équivalent au ''branch target buffer'', qui mémorise les instructions fautives (leur ''Program Counter''), avec d'autres informations comme les adresses des instructions dépendantes. La première classe de techniques du genre consiste à mémoriser les lectures qui ont causé une violation de dépendance, tandis que l'autre ne mémorise que les écritures. Cette dernière est l'approche utilisée par le '''cache de barrières d’écriture''' (store barrier cache). Enfin, nous allons conclure avec une dernière technique : celle des '''ensembles d’écritures''' (store sets). Cette technique est capable de gérer le cas où une lecture dépend de plusieurs écritures : le processeur mémorise l'ensemble des écritures avec lesquelles la lecture a déjà eu une dépendance. Cet ensemble d'écritures associées à une lecture est appelé tout simplement un ensemble d’écritures, et reçoit un identifiant (un nombre) par le processeur. Cette technique demande d'utiliser deux tables : * une qui assigne un identifiant d’ensemble d’écritures à l'instruction en cours ; * une autre qui mémorise les ensembles d’écritures sous la forme d'une liste chainée : le début de la liste correspond à l'écriture la plus ancienne de l’ensemble. Cela permet d'obtenir l'écriture la plus ancienne dans cet ensemble d’écritures directement. Quand le processeur détecte une violation de dépendance entre une lecture et une écriture, elle ajoute l'écriture dans l’ensemble d’écritures adéquat. Le déroulement d'une lecture demande d'accéder à la première table pour récupérer l'identifiant de l’ensemble d’écritures, et l'utiliser pour adresser la seconde table. S'il y a dépendance, cet accès renvoie l'écriture fautive en cas de dépendance. Quand une écriture est envoyée à l'unité mémoire, celle-ci va accéder à la table de correspondances de la même manière qu'une lecture, et va récupérer l'identifiant de l’ensemble d’écritures auquel elle appartient, identifiant qui est utilisé pour vérifier s'il y a une écriture en cours d’exécution. Si c'est le cas, cela signifie que l'écriture en cours d’exécution doit s’exécuter avant l'écriture qui a consulté la table : cette dernière est mise en attente. ==La prédiction d'adresse et de valeur== D'autres techniques de prédiction mémoire plus élaborées que les précédentes existent. Il s'agit de la prédiction d'adresse et de la prédiction de valeur. Leur nom trahit ce qu'elles font. La première technique tente de prédire l'adresse d'une lecture/écriture, alors que la seconde tente carrément de prédire quelle sera la donnée lue. Il y a bien de la recherche académique sur le sujet, mais il s'agit de techniques assez osées, dont on voit mal comment elles pourraient fonctionner. L'efficacité de cette technique a été étudiée grâce à des simulateurs. Suivant les études ou les programmes, on trouve des résultats qui varient pas mal. Dans certains cas, la performance baisse un peu, et dans d'autres, on peut avoir des gains de plus de 10% ! Mais dans des cas normaux, on trouve des gains de 4-5% environ. Ce qui est tout de même pas mal à l'heure actuelle. Cependant, en Janvier 2025, à l'heure où j'écris ces lignes, il a été découvert la première utilisation de ces deux techniques dans un processeur commercial. La découverte fait suite à la publication de deux vulnérabilités matérielles sur les CPU Apple M2 et M4 et quelques CPU ARM, qui font justement usage de ces deux techniques. Les deux vulnérabilités, appelées SLAP et FLOP, sont assez similaires aux attaques Spectre et Meltdown, sauf qu'elles attaquent non pas la prédiction de branchement, mais la prédiction d'adresse et de valeur du CPU. ===La prédiction d'adresse=== La prédiction d'adresse tente de prédire l'adresse d'une lecture à l'avance, ce qui permet de lancer les lectures en avance. Si la prédiction est correcte, on gagne quelques cycles qui permet aux unités de calcul de faire leurs calculs plus en avance, sans compter que les mécanismes de désambiguïsation mémoire fonctionnent plus efficacement. Si jamais cette adresse est connue plus tôt, on détecte les dépendances plus rapidement et on peut agir en conséquence. Des variantes de ces unités de prédiction d'adresse sont utilisées pour précharger des données depuis le cache de donnée. Et leur efficacité est assez bonne, voire excellente dans certains cas. La différence est que la prédiction d'adresse est utilisée pour prédire les adresses à lire depuis le cache vers les registres, ce qui est le sujet de cette sous-partie. La prédiction d'adresse est réalisée par une sorte de cache, qui associe à chaque instruction la prochaine adresse à lire. Nous appellerons ce cache la '''table de prédiction d'adresse'''. Pour faire l'association instruction <-> adresse lue/écrite, le tag du cache utilise l'adresse de l'instruction (le ''program Counter''), alors que la ligne de cache associée contient l'adresse prédite. Son contenu est utilisé pour faire une prédiction. Lorsqu'une lecture est émise, la table est consultée pour récupérer en avance l'adresse à lire, avant que celle-ci soit calculée par l'ALU. La lecture est alors exécutée en avance, avant que la vraie adresse soit connue, en utilisant l'adresse prédite. Reste qu'il faut des circuits autour de cette table de prédiction d'adresse, pour déterminer si la prédiction est correcte, ainsi que pour remplir cette table avec une adresse prédite. Pour cela, il y a un circuit de prédiction d'adresse dédié. Il récupère les instructions qui quittent le ROB ou la file des lectures mémoire, afin de vérifier les lectures qui viennent de se terminer/''commit'' et qui quittent le pipeline. Il peut contenir une '''table d'apprentissage''', qui mémorise des informations nécessaires pour faire des prédictions d'adresse. Par exemple, un historique des dernières adresses lues pour les lectures les plus récentes, ou un historique des adresses des lectures. La table est aussi utilisée pour déterminer si les prédictions effectuées étaient correctes ou non. Pour cela, il est possible de réutiliser les techniques vues dans le chapitre sur la prédiction de branchement, notamment des compteurs à saturation. Pour la prédiction de branchement et les autres formes de prédiction mémoire, les table d'apprentissage et pour les instructions/adresses sont fusionnées en une seule. Mais ici, il est nécessaire de les séparer pour une raison assez particulière : éliminer la pollution du cache dans la table de prédiction d'adresse. On ne peut pas allouer une ligne de cache pour chaque nouvelle instruction de lecture exécutée. Seule une minorité des lectures profitent de la prédiction d'adresse. Il est nécessaire de filtrer les lectures pour ne conserver que celles qui profitent de la prédiction d'adresse, ce qui est possible plus facilement en séparant la table d'apprentissage et les compteurs à saturation de la table de prédiction d'adresse. Une première méthode consiste à prédire que la lecture lit toujours la même adresse, ce qui lui vaut le nom de '''prédiction d'adresse constante'''. On pourrait se demander pourquoi une telle situation surviendrait. La raison est qu'il arrive que les compilateurs n'arrivent pas à gérer les accès mémoires efficacement. Dans certaines situations un peu compliquées impliquant des pointeurs ou des références, divers phénomènes complexes d'''aliasing'' des pointeurs peuvent générer des relectures intempestives de données en mémoire. Cela peut aussi arriver lorsqu'on compile du code qui provient de plusieurs librairies, ou quand du code lit régulièrement des constantes en mémoire ROM. La prédiction d'adresse constante ajoute un circuit qui analyse les dernières lectures, une fois qu'elles quittent le ROB, qu'elles ''commit''. Si, pour une instruction de lecture, l'adresse lue est toujours la même, alors l'instruction est ajoutée dans la table de prédiction d'adresse. Vu que les instructions qui accèdent toujours à la même adresse sont rares, il est préférable d'initialiser les compteurs à saturation de façon à ce qu'ils disent que toute nouvelle instruction change d'adresse. Il s'agit d'une méthode simple mais peu efficace, car cette situation est rare. Une autre méthode détecte les accès en enjambées, là encore quand les lectures quittent le ROB. Nous l’appellerons la '''prédiction d'adresse en enjambées'''. De tels accès surviennent régulièrement quand on accède des tableaux ou d'autres structures de données semblables, ce qui rend la technique très intéressante. Implémenter cette technique est facile : il suffit d'ajouter un circuit qui détecte les accès en enjambées, au lieu de celui qui détecte les accès constants. À chaque fois qu'une lecture entraine un succès de cache dans la table de prédiction d'adresse, l'enjambée est ajoutée à l'adresse contenue dans la table de prédiction d'adresse, l'ancienne adresse prédite est remplacée par celle avec l'enjambée d'ajoutée. Sur les Apple M2-M4, la prédiction d'adresse gére aussi bien les adresses constantes que les accès en enjambée. Le brevet qui décrit l'implémentation de l'unité de prédiction d'adresse et de valeur est potentiellement le suivant : [https://patents.google.com/patent/US11829763B2/en Early load execution via constant address and stride prediction]. En théorie, il est possible de faire mieux en repérant des accès mémoires qui se répètent de façon régulière et cyclique, même s'ils n'ont aucune enjambée. Ce genre d'accès se trouve assez souvent lorsque l'on manipule des listes chainées ou des structures de données assez irrégulières comme des arbres, des graphes, etc. Mais il n'y a pas encore de CPU qui implémente de telle optimisation. ===La prédiction de valeur : prédire la donnée lue=== Les techniques de '''prédiction de valeur''' (''Value Prediction'') consistent à prédire quelle est la donnée qui sera lue par une instruction de lecture. Oui, vous avez bien lu : le processeur est capable de parier sur la valeur qui sera chargée depuis la mémoire, de prédire si cette valeur vaut 0, 1, 1024, etc. Une fois son pari fait, il exécute les instructions du programme avec la valeur qu'il a pariée de façon spéculative. Si le pari est correct, le CPU continue l’exécution. Sinon, il est obligé de vider le pipeline et de recommencer avec la bonne valeur. Au premier abord, cette technique semble franchement mal partie tellement les chances de se tromper sont tellement énormes ! Mais le fait est que, dans certains cas, il est possible de spéculer correctement. Là encore, les techniques les plus simples fonctionnent dans deux cas. Le premier est celui où une donnée est relue à l'identique à chaque fois que la lecture est exécutée. Le second est celui où la donnée est incrémentée/décrémentée d'une constante qui est toujours la même d'une exécution de la lecture à l'autre. Une sorte d'enjambée constante, mais pour les données. Nous allons nous concentrer sur le premier cas, celui où une donnée est relue plusieurs fois sans être modifiée entre temps. L'idée de base est qu'une instruction de lecture qui s'exécute plusieurs fois de suite à la même adresse renvoie la même valeur à chaque fois. C'est parfaitement possible s’il n'y a eu aucune écriture à cette adresse entre-temps, d'où le fait qu'il faille surveiller si les prédictions constantes sont correctes. On peut se demander quelles sont les raisons qui font qu'une instruction de lecture renvoie la même valeur à chaque fois. Après tout, autant lire une seule fois la donnée et la garder dans un registre une bonne fois pour toutes ! Mais dans la réalité, on fait face à quelques limites, qui seront détaillées dans la liste suivante. La première est que le manque de registre fait que certaines données sont conservées en RAM et sont relues fréquemment. Cela arrive quand on stocke des constantes en mémoire. Par exemple, sur les processeurs x86, les constantes flottantes ne peuvent pas être intégrées dans nos instructions via le mode d'adressage immédiat. À la place, on les stocke en mémoire et on les charge dans les registres à chaque fois qu'on en a besoin. L'implémentation est similaire à la prédiction d'adresse, si ce n'est que c'est la donnée lue qui est prédite. Là encore, on a une '''table de prédiction de donnée lue''', consultée lorsqu'une lecture est émise. La table de prédiction de la donnée lue est une mémoire cache qui associe le ''Program Counter'' de la lecture à la donnée prédite, éventuellement couplé à des compteurs à saturation. Elle est consultée à chaque fois qu'une lecture est émise, la donnée adéquate est récupérée lors d'un succès de cache. On trouve aussi un circuit qui insère ou retire des instructions de la table de prédiction de donnée lue. Elle détecte les lectures prédictibles, couplé à une table d’apprentissage, et quelques circuits annexes liés au ROB ou aux files de lectures. Elle contient au minimum des compteurs à saturations, associés chacun à une instruction (son ''program counter'' pour être précis). ==L'optimisation du ''Mirroring memory operand''== Les processeurs modernes implémentent une optimisation concernant les accès mémoire. Cette optimisation a été décrite par Agner Fog dans ses manuels d'optimisation, sous le terme de '''''Mirroring memory operand'''''. Il a documenté son apparition sur les processeurs Zen 2 d'AMD, puis son évolution sur les processeurs Zen 4 et 5. L'optimisation est bizarrement absente de la microarchitecture Zen 3. Elle se manifeste dans une condition très précise : plusieurs instructions proches accèdent à la même adresse. Les deux instructions ne sont pas forcément consécutives, il peut y avoir une dizaine ou centaine d'instructions entre les deux. Une telle situation survient souvent sur les CPU CISC, et notamment sur les CPU x86, car ils ont peu de registres. les compilateurs font donc beaucoup d'échanges entre pile d'appel et registres, ce qui fait que des données écrites dans la pile sont souvent relue quelques instructions plus tard. Dans ce cas, la donnée lue/écrite est mémorisée dans un registre interne au processeur et peut être accédée très rapidement. Par exemple, imaginons plusieurs lectures proches à la même adresse, sans écriture entre elles. Dans ce cas, le processeur ne fera qu'une seule lecture en mémoire RAM, les lectures ultérieures liront la donnée depuis le registre interne. Comme autre exemple, imaginons une écriture suivie plus tard par une lecture à la même adresse. Dans ce cas, la donnée sera écrite dans un registre interne, en plus d'être propagée dans le cache ou la RAM, et la lecture ultérieure lira directement le registre interne. Le gain en performance est alors conséquent, comparé à du simple ''store-to-load forwarding''. Il faut noter que la technique marche aussi pour les opérations ''load-op'', pas seulement pour les instructions d'accès mémoire. Par exemple, prenons une écriture en RAM, suivies quelques instructions plus tard par une opération ''load-op'' à la même adresse. L'écriture écrit à une adresse, qui est lue par l'instruction ''load-op''. L'optimisation s'applique : l'instruction ''load-op'' lira l'opérande mémoire depuis un registre interne du processeur, qui contient une copie de la donnée écrite. Sur les processeurs Zen 2, l'optimisation marchait aussi pour les instructions PUSH et POP, qui lisaient/écrivaient dans la pile d'appel. Mais cette possibilité a été retirée sur les processeurs Zen 5. La technique détecte que deux instructions accèdent à la même adresse sous certaines conditions bien précises. L'optimisation ne marche que pour les modes d'adressages indicés et indirect, avec ou sans décalages, mais pas en mode absolu ou relatifs. Il faut préciser que l'optimisation ne marche que pour les registres généraux, pas les registres flottants. Les registres doivent être précisés de la même manière dans toutes les instructions. Par exemple, le processeur ne reconnait pas que l'addition des deux registres RCX + RAX est la même que RAX + RCX. Sur les CPU Zen 2 et 4, elle ne marchait que pour des opérandes de 32 et 64 bits, mais les processeurs Zen 5 gèrent des données de 8 et 16 bits aussi. Les décalages étaient limités à 8 bits sur le Zen 2, mais n'a plus de limites sur les Zen 4 et 5. ==La désambiguïsation mémoire a des effets de bord== La désambiguïsation mémoire a une caractéristique que l'exécution dans le désordre simple n'a pas : elle a des effets qui débordent sur la mémoire. Il est possible de savoir si un processeur fait de la désambiguïsation mémoire en regardant le bus mémoire. On s’aperçoit alors que les lectures se font dans un ordre différent de celui du programme. À l'opposé, l'exécution dans le désordre n'a pas d'effets observables de ce genre. Elle améliore la performance, elle modifie les durées observables des instructions, mais cela se limite à des questions de ''timings''. Le seul moyen de savoir si un processeur fait de l'exécution dans le désordre est de mesurer les latences/durées de chaque instruction. Mais aucun état observable au-delà des ''timings'' n'est modifié. Pas avec la désambiguïsation mémoire. ===La consistance mémoire=== Le fait que la désambiguïsation mémoire ait des effets observables ne pose aucun problème avec un seul processeur, ca elle est conçue pour. Par contre, les techniques de désambiguïsation mémoire peuvent poser des problèmes avec plusieurs processeurs. Il arrive que des programmes partagent une même portion de mémoire, et lisent/écrivent dedans. Avec plusieurs processeurs, chaque processeur effectue la désambiguïsation mémoire dans son coin sans se préoccuper des autres. Les opérations d'écriture peuvent être mises dans le désordre du point de vue des autres processeurs, et les lectures effectuées par les autres processeurs peuvent alors renvoyer de vielles données. Un exemple classique est celui de deux cœurs qui partagent le même cache ou la même mémoire, mais qui utilisent chacun une file d'écriture (''store buffer''). Imaginons que le premier cœur effectue une écriture, puis une lecture, à des adresses différentes. L'écriture est retardée grâce au tampon d'écriture, alors que la lecture lit directement le cache. Pour le premier processeur, l'écriture s'est réalisée avant la lecture, car pour lui, la fin de l'écriture signifie écrire dans le tampon d'écriture. Mais pour le second cœur, l'ordre s'est inversé : il voit la lecture dans le cache, puis l'écriture dans le cache. La raison est qu'il n'a pas connaissance du contenu du tampon d'écriture de l'autre cœur. Un autre exemple : les techniques de prédiction d'adresse et de valeur posent des problèmes avec les dépendances RAW. Elles permettent à une instruction mémoire de s'exécuter avant une autre, malgré la présence d'une dépendance RAW entre les deux. L'ordre entre les deux instructions change, alors que les autres processeurs ne devraient pas le voir. Et dans les faits, les processeurs qui incorporent ces techniques ont des modèles de consistance mémoire qui ne permettent pas ces optimisations en cas de dépendances RAW. Les deux techniques doivent idéalement être désactivées pour les instructions mémoire qui ont des dépendances RAW. Les programmeurs doivent tenir compte de ce genre de situations, dans des cas assez rares et liés à la programmation bas niveau. Pour cela, ils doivent savoir comment le processeur réorganise les accès mémoire. Concrètement, chaque processeur définit un '''modèle de consistance mémoire''' qui indique quelles optimisations sont activées et non. Il s'agit d'une sorte de contrat entre le logiciel et le matériel : les programmeurs savent comment le processeur va réorganiser les lectures et écritures et peuvent coder leurs programmes en fonction. L'implémentation du système de consistance mémoire dépend grandement de l'architecture, mais des modèles ont été standardisés afin de permettre aux programmeurs de savoir où placer les barrières mémoires. Il s'agit de vrais standards, formalisés, parfois mathématiquement, que des chercheurs étudient sur un plan mathématique et algorithmique. Le modèle préféré des programmeurs est la '''consistance séquentielle''', où les différents processeurs effectuent des accès mémoire dans l'ordre du programme et les accès mémoire simultanés sont interdits. La première contrainte interdit l'usage de mécanismes de désambiguïsation mémoire, la seconde interdit l'usage de caches non-bloquants. La consistance séquentielle a donc un cout en performance assez important, ce qui fait que les processeurs modernes n'utilisent pas la consistance séquentielle. Un modèle plus flexible est le '''''total store ordering''''', qui autorise la présence d'une file d'écriture et l'usage de caches non-bloquants pour les lectures (interdit d'avoir plusieurs écritures simultanées). C'est plus ou moins celui utilisé par les processeurs x86, comme on le verra plus bas. Il existe des modèles de consistance mémoire plus relâchés, appelés des '''modèles de consistance faible''', qui autorisent diverses optimisations de spéculation mémoire, la combinaison d'écriture (fusionner deux écritures à la même adresse en une seule), etc. Ils sont supportés sur les architectures ARM et POWER. ===Les barrières mémoire=== Le changement d'ordre des lectures et écritures peut poser occasionnellement des problèmes avec la programmation pour les architectures multi-processeur ou multicœurs. Il existe des morceaux de code qui ne marchent que si la consistance séquentielle est respectée et il faut faire en sorte qu'ils marchent sur des processeurs avec un modèle de consistance mémoire différent. Pour cela, les processeurs incorporent des instructions appelées '''barrières mémoires''' ou '''''Fences'''''. Elles forcent le processeur à terminer tous les accès mémoire qui précédent dans l'ordre du programme. Certains processeurs possèdent une barrière mémoire pour les lectures et une autre pour les écritures. D'autres processeurs ajoutent des barrières mémoires dédiées à des cas particuliers. Elles permettent de forcer les accès à des données partagées à dérouler correctement. [[File:Fences.png|centre|vignette|upright=2|Fences.]] Leur implémentation est assez simple : elles empêchent l'émission de toute nouvelle instruction mémoire, tant qu'une condition précise n'est pas réunie. Par exemple, la barrière mémoire pour les écritures attend que la file d'écriture soit vidée avant de démarrer le moindre accès mémoire. Un compilateur peut placer les barrières mémoires au bon endroit, avec un peu d'aide du programmeur. Certains langages de programmation permettent d'indiquer au compilateur qu'une donnée doit toujours être lue depuis la mémoire RAM, via le mot-clé volatile. C'est très utile pour préciser que cette donnée est potentiellement partagée par plusieurs processeurs ou manipulable par des périphériques. Les compilateurs peuvent placer des barrières mémoires lors des lectures ou écritures sur ces variables, mais ce n'est pas une obligation. Il arrive aussi que le programmeur doive manipuler explicitement des barrières mémoires. Utiliser l'assembleur est alors une possibilité, mais qui est rarement exploitée, pour des raisons de portabilité. Pour limiter la casse, certains systèmes d'exploitations ou compilateurs peuvent aussi fournir des barrières mémoires explicites, encapsulées dans des bibliothèques ou cachées dans certaines fonctions. ===La modèle de consistance des processeurs x86=== Après avoir vu la théorie, passons maintenant à la pratique. Dans cette partie, on va voir les modèles de consistances utilisés sur les processeurs x86, ceux qu'on retrouve dans les PC actuels. Le modèle de consistance des processeurs x86 a varié au cours de l'existence de l'architecture : un vulgaire 486DX n'a pas le même modèle de consistance qu'un Core 2 duo, par exemple. Ils ont évolués au cours du temps, avec l'arrivée de nouvelles optimisations. De plus, leur formalisation a été progressive, et s'est faite au fur et à mesure. En général, les modèles de consistance des processeurs x86 ont toujours étés assez restrictifs, si on les compare aux autres processeurs. Il s'agit de modèles de type ''total store ordering'' (TSO), au moins dans les grandes lignes. Ils permettent donc d'effectuer certaines lectures en avance, soit parce que les lectures se font à des adresses différentes, soit par utilisation de réacheminement écriture vers lecture. Les possibilités d'exécution en avance des lectures varient suivant le processeur, elles deviennent de plus en plus performantes avec le temps. Le premier modèle de consistance est apparu sur les premiers processeurs x86 et est resté en place sur tous les processeurs de marque Pentium. Les processeurs de l'époque n'incorporaient pas beaucoup d'optimisations, le budget en transistor n'était pas assez conséquent pour ça, la mémoire n'était pas assez lente. Le modèle TSO de l'époque n'autorisaient que peu de lectures anticipées. Une lecture ne peut être exécutée en avance que sous les conditions suivantes : * les écritures contournées se font dans la mémoire cache ; * la lecture doit se faire dans la mémoire RAM ; * les écritures se font à une adresse différente de la lecture ; * aucune transaction avec un périphérique ne doit être en cours. A partir du Pentium 4, les choses changent. Le Pentium 4 est en effet le premier processeur à implémenter des techniques permettant d’exécuter plusieurs processus en parallèle, dont l'Hyperthreading. En conséquence, le modèle de consistance a dû être assoupli pour éviter de perdre bêtement en performance. Il s'agit d'un vrai modèle de type TSO, la majeure partie des contraintes sur les lectures anticipées sont réduites au minimum. Il y a aussi des contraintes supplémentaires pour les instructions dites atomiques, ainsi que pour les instructions qui accèdent aux périphériques ou aux entrées-sorties. Une optimisation séparée du TSO est que les écritures en mémoire peuvent changer d'ordre dans un cas exceptionnel : les écritures effectuées par les instructions de copie mémoire comme REPMOVSD, REPSCACB, ainsi que les instructions SSE MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, MOVNTPD. Les écritures effectuées dans ces instructions peuvent se faire dans un désordre complet (ou presque). Lors du passage au 64 bits, le modèle mémoire des processeurs x86 de l'époque a été formalisé dans un papier d'Intel appelé [https://www.cs.cmu.edu/~410-f10/doc/Intel_Reordering_318147.pdf Intel® 64 Architecture Memory Ordering White Paper], mis à jour en 2008 suite à quelques problèmes techniques. La formalisation du modèle a été d'une grande aide aux programmeurs, qui devaient se débrouiller avec des documentations qui ne formalisaient pas de modèle de consistance précis. Le document décrit un modèle de consistance mémoire portant le nom barbare de ''total lock order + causal consistency” (TLO+CC)'', qui admet plus d'optimisations que le modèle TSO. Mais la description était en réalité légèrement différente du modèle mémoire réellement implémenté sur les processeurs existants, qui utilisaient du TSO, sauf en de rares occasions. Les concepteurs de processeur font souvent cela, à savoir définir un modèle de consistance plus laxiste que réellement implémenté, pour des questions de comptabilité. Ainsi, si les futurs processeurs de la marque implémentent un modèle plus laxiste, les programmes déjà codés continuent de fonctionner. Le modèle décrit dans la documentation laisse de la marge pour le futur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le parallélisme mémoire | nextText=Le parallélisme mémoire }} </noinclude> 2j8zthwlwmavkocmfiknfbjjvjtpd83 774133 774132 2026-10-04T13:47:24Z Mewtow 31375 /* L'exécution anticipée des calculs d'adresse */ 774133 wikitext text/x-wiki Dans les chapitres précédents, nous avons vu la mal-nommée exécution dans le désordre dans les chapitres précédents, qui devrait plutôt s'appeler l'émission dans le désordre. Elle modifie l'ordre des instructions pour gagner en efficacité, mais cela n'est valable que pour les instructions travaillant sur des registres ! Nous n'avons pas parlé de l'exécution dans le désordre des accès mémoire, car ils sont un peu à part. Et pour comprendre pourquoi, nous allons dédier ce chapitre à la gestion des accès mémoire dans le pipeline. Avant de poursuivre, faisons un rappel rapide sur les dépendances de données. Il faut distinguer deux types de dépendances de données : les dépendances de registres (deux instructions manipulent le même registre), et les dépendances d'adresse (deux instructions manipulent la même adresse). Deux instructions ont une dépendance d'adresse si elles écrivent ou lisent la même adresse mémoire. Des instructions mémoires qui lisent/écrivent des adresses différentes sont indépendantes. Lorsque deux instructions mémoire lisent/écrivent à la même adresse, on parle d''''aliasing mémoire'''. Formellement, l'aliasing mémoire n'est ni plus moins que l'application des dépendances de données aux instructions mémoire. Et comme les autres dépendances de données, il existe plusieurs types d'aliasing mémoire : RAR, RAW, WAR et WAW. Les dépendances RAR ne posent aucun problème, les lectures peuvent changer d'ordre sans aucun problème, tant qu'il n'y a pas d'écritures entre les deux. Par contre, les autres dépendances imposent trois contraintes. * Les dépendances RAW imposent que les lectures ne doivent pas s'exécuter avant une écriture à la même adresse. * Les dépendances WAR imposent que l'on ne peut pas déplacer une écriture avant une lecture à la même adresse. * Enfin, les dépendances WAW font que l'ordre des écritures doit être celui du programme : impossible de changer l'ordre de deux écritures. Les trois contraintes peuvent s'implémenter de plusieurs manières différentes. Comme pour les dépendances de données normales, seules les dépendances RAW sont de vraies dépendances de données. Les deux autres dépendances viennent du fait que l'on utilise la même adresse pour stocker des valeurs différentes. Détecter les cas d'''aliasing'' mémoire est primordial pour gérer la désambiguïsation mémoire, et cela demande de comparer des adresses. S'il existe des techniques similaires au renommage de registre permettent de les faire disparaitre, elles sont très limitées. Diverses optimisations permettent d'émettre des accès mémoire dans le désordre, exactement comme l’exécution dans le désordre le fait pour les autres instructions. Elles utilisent pour cela des ''schedulers'' spécialisées dans les accès mémoire. Il faut vraiment insister sur le fait que l'exécution dans le désordre proprement dite ne se préoccupe pas des micro-opérations mémoire. Pour faire la distinction, on parle de '''désambiguïsation mémoire''' (''memory disambiguation'') pour parler de l'exécution dans le désordre des accès mémoire. Le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. La raison est que la donnée lue est utilisée par d'autres instructions, dépendantes de la lecture. Elles sont donc à la tête d'une chaine de dépendances de données qui peut bloquer le pipeline si elle n'est pas résolue très tôt. Dès qu'une lecture peut être lancée, elle accède directement au cache de données. Par contre, si l'''aliasing'' ne le permet pas (écriture à la même adresse pas encore effectuée) ou que l'adresse à lire n'a pas encore été calculée, la lecture attend son tour. Le problème est que la désambiguïsation mémoire demande de comparer des adresses, pour détecter les dépendances d'adresse. Et cela pose de sérieuses contraintes pour son implémentation. L'exécution dans le désordre normale compare des registres, ce qui est beaucoup plus simple. Les registres utilisés sont directement encodés dans l'instruction elle-même, ce qui fait qu'on peut détecter les dépendances de registre après le décodage. Et elles peuvent même être éliminées par le renommage de registre pour les dépendances WAW et WAR. Par contre, les adresses ne sont pas encodées dans l'instruction elle-même (sauf avec le cas particulier de l'adressage absolu), mais sont lues depuis les registres ou calculées par une unité de calcul. Les adresses ne sont donc connues qu'une fois que l'instruction a attendu suffisamment de temps dans le ''scheduler''. ==L’émission dans l'ordre des accès mémoire== Avant toute chose, faisons quelques rappels sur l'exécution dans le désordre "normale", sans désambiguïsation mémoire. Les micro-opérations renommées sont mises en attente dans un ''scheduler'', le temps que leurs opérandes soient disponibles. Les µops mémoire font cependant exception. ===La file de µops mémoire=== Avec l''''émission dans l'ordre des accès mémoire''', le processeur émet les accès mémoire dans l'ordre du programme. Cela implique que l'unité mémoire n'a pas de ''scheduler'', ni de station de réservation associée. À la place, les micro-opérations mémoire sont placées dans une file de micro-opération dédiée, appelée la '''file de µops mémoire'''. La file de µops mémoire est une mémoire FIFO, dont chaque entrée peut accueillir une µops mémoire. [[File:Unité de gestion des accès mémoires dans l’ordre.png|centre|vignette|upright=2.0|Unité de gestion des accès mémoires dans l’ordre]] Par contre, le processeur utilise des ''schedulers'' pour les unités de calcul. Autant les accès mémoire se font dans l'ordre, autant les instructions arithmétiques/logiques/branchements/autres sont exécutées dans le désordre. En clair, le processeur supporte l'exécution dans le désordre, sauf pour les micro-opérations mémoire. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] Vu de l'extérieur, il faut attendre qu'un accès mémoire soit terminé pour lancer le suivant. Avant d'émettre une micro-opération mémoire, la file de micro-opération (son ''scoreboard'') vérifie si l'unité d'accès mémoire est libre ou non. Si elle est occupée, un accès mémoire est en cours, et on ne peut pas émettre de micro-opération mémoire. Une fois l'accès mémoire terminé, la mémoire ou le cache envoient un signal pour indiquer que l'accès est fini, et c'est au tour d'une nouvelle micro-opération mémoire. L'unité mémoire a un port sur lequel on envoie la micro-opération émise, que ce soit une écriture ou une lecture. Une file de µops implique la ''lecture après émission'', à savoir que les µops mémoire quittent la file de µops, avant de lire les registres adéquats. Il y a donc une connexion entre banc de registre et unité mémoire, comme sur n'importe quel autre processeur. Mais la file de µops n'est pas connectée au banc de registre, ni au réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] L'organisation précédente laisse penser que les calculs d'adresse sont forcément réalisés dans l'unité mémoire. Mais ce n'est pas une obligation. Il est parfaitement possible de scinder une µops mémoire en deux : une µop mémoire proprement dite et une µop de calcul d'adresse. La liaison entre les deux doit cependant se faire en utilisant un registre. L'adresse calculée est enregistrée dans un registre d'adresse "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. ===La file d'écriture : une FIFO de mise en attente des écritures=== L'unité mémoire exécute une µops mémoire à la fois. Avec un détail particulier : les écritures doivent attendre que toutes les instructions se terminent, avant de pouvoir s'exécuter. Sans ça, les exceptions précises sont impossibles, la prédiction de branchement l'est aussi et l'exécution dans le désordre aurait des conséquences fâcheuses (en exécutant des écritures en avance). Précisément, les écritures doivent respecter deux contraintes : s'exécuter dans le tout dernier étage du pipeline, s'exécuter dans l'ordre du programme. Il serait possible de bloquer l'émission des écritures tant que le ROB ne donne pas son accord. Mais ce n'est pas du tout ce qui est fait. A la place, les écritures sont mises en attente dans une mémoire FIFO, appelée la '''file d'écriture''', ou encore la ''Write Queue''. Les écritures sont mises en attente, jusqu'à ce que le ROB leur donne le okay pour transfer l'écriture en mémoire RAM. Nous en avions déjà parlé dans le chapitre sur le pipeline, dans la section sur les exceptions précises. Mais quelques rappels ne feront pas de mal. L'écriture est insérée dans la file d'écriture lorsqu'elle est émise, elle en sort à l'étage de ''Writeback'' si tout se passe bien. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente, faites à tort. De plus, la file d'écriture est une mémoire FIFO, qui mémorise les écritures dans l'ordre du programme. En effet, les écritures sont ajoutées dans la file d'écriture lorsqu'elles sont émises, et elles sont émises dans l'ordre du programme. [[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Write Queue sur un pipeline fixe]] La file d'écriture garantit que les écritures sont dans l'ordre du programme. Et cette propriété élimine totalement les dépendances WAW. Le fait que les écritures se font à la fin du pipeline élimine quant à elle les dépendances WAR. Il ne reste plus qu'à gérer les dépendances RAW, celles où une lecture lit une donnée écrite par une autre instruction. Une telle situation survient, car les écritures sont mises en attente pendant un certain temps, la donnée écrite peut être lue pendant ce laps de temps. La lecture ne peut pas lire la donnée dans le cache, car la donnée à lire n'y est pas encore enregistrée. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire, mais il existe. Il se manifeste surtout sur les processeurs avec peu de registre, où des données doivent régulièrement être déplacées temporairement en mémoire RAM. Pour éviter cela, une solution consiste mettre en attente les lectures si une écriture est en attente. Les lectures sont maintenues dans la file de µops tant que la file d'écriture n'est pas vide. La file de µops mémoire est suivie d'un circuit de détection des dépendances, un '''''scoreboard'' mémoire''' qui est assez simple. L'unité mémoire fournit un bit qui indique si la file d'écriture est vide ou non, ainsi qu'un second bit qui indique qu'elle est vide. Les deux ne sont pas compliqués à générer, ils font partie intégrante de l'interface de toute mémoire FIFO digne de ce nom. Le ''scoreboard'' mémoire utilise ces deux bits pour savoir s'il peut émettre une micro-opération mémoire. Il n'émet pas de lecture tant que la file d'écriture n'est pas vide. Il peut par contre émettre des écritures tant qu'elle n'est pas pleine. Une meilleure solution serait d'interdire les lectures, mais seulement en cas de dépendance mémoire détectée. Et c'est là qu'intervient la technique du '''''load bypassing'''''. Elle autorise une lecture, si elle n'a aucune dépendance avec les écritures mises en attente dans la file d'écriture. Elle doit être mise en attente dans le cas contraire. Pour l'implémenter, il faut comparer l'adresse à lire avec toutes les adresses dans la file d'écriture. La lecture est exécutée immédiatement si aucune correspondance n'est trouvée, mais elle est mise en attente dans le cas contraire. Pour cela, la file d'écriture est modifiée et devient un mélange entre FIFO et mémoire associative. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'' : la lecture doit être bloquée. Sinon, c'est un défaut de ''Write Queue'', la lecture accède au cache, là où se trouve la donnée à lire. Dans le chapitre sur le contournement, nous avons parlé d'une forme de contournement qui corrige ce problème. Les lectures peuvent lire la donnée depuis la file d'écriture si besoin, ce qui renvoie la donnée adéquate. Pour cela, les lectures consultent à la fois le cache et la file d'écriture, comme dit plus haut. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'', ou encore de '''réacheminement écriture-vers-lecture'''. [[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]] La lecture est mise en attente dans l'unité mémoire, vu que c'est elle qui fait les comparaisons d'adresse. Du point de vue extérieur, le ''scoreboard'' mémoire ne voit pas s’il y a eu succès dans la file d'écriture ou non. Il voit juste que l'unité mémoire est occupée et ne peut pas accepter de nouvelle lecture. Elle reste occupée durant quelques cycles, en cas de défaut de ''Write Queue'', le temps de faire la lecture dans le cache. Par contre, en cas de succès de ''Write Queue'', elle reste occupée tant que la lecture n'a pas donné de résultat, ce qui inclue le temps de mise en attente. Les processeurs haute performance implémentent tous le ''Store to load Forwarding'', mais quelques processeurs basse performance ne le font pas. Ils se contentent du ''load bypassing'', qui offre des performances correctes pour un cout en matériel bien plus modeste. Pour donner un exemple, la micro-architecture Jaguar d'AMD était dans ce cas. Elle a été utilisée sur les consoles de jeu de 8ème génération, comme la Xbox One et la Playstation 4. Les processeurs haute performance préférent utiliser plus de circuits pour implémenter le ''Store to load Forwarding'', mais l'implémentation n'est souvent pas complète. En effet, l'implémentation est souvent simple et ne prend en compte que des lectures/écritures à des adresses identiques, sans compter que la taille des données doit aussi être identique. Une écriture d'un entier de 32 bit sera contournée et envoyée à une lecture de 32 bits à la même adresse. Mais la lecture lit seulement 16 bits sur les 32 écrits, le contournement peut ne pas marcher. Concrètement, ce cas précis fonctionnera. Mais le cas inverse, avec une lecture lisant plusieurs écritures de taille plus petite, ne marchera pas sur tous les processeurs. Ne parlons pas du cas où les données écrites sont à cheval sur deux lignes de cache. ==L'exécution anticipée des calculs d'adresse== L'exécution dans le désordre fonctionne mieux si les lectures sont exécutées le plus tôt possible. Les lectures lisent des données qui sont utilisées par d'autres instructions. Si la lecture tarde, les autres instructions sont mises en attente. Exécuter les lectures le plus tôt possible est donc crucial. Mais comment faire ? Un moyen pour ça est de faire en sorte que les adresses à lire/écrire soient connues le plus tôt possible. Mais cela implique que les calculs d'adresse ne sont pas ffait en même temps que l'accès mémoire. Pour cela, il est possible de séparer les µops mémoire en deux micro-instructions : une pour le calcul d'adresse et l'autre pour les accès mémoire. On parle de '''calcul anticipé des adresses'''. ===La ''Load/Store Queue'' : une file de µops améliorée=== Implémenter cette technique demande de faire deux choses. Premièrement, il faut faire les calculs d'adresse en-dehors de l'unité mémoire, soit dans une ALU entière, soit dans une unité de calcul d'adresse dédiée. Deuxièmement, les adresses calculées doivent être mises en attente quelque part, et être liées facilement à la µop mémoire associée. Pour le second point, la file de µops mémoire parait être un bon candidat, mais n'est pas exactement la solution. Une file de µops au sens strict ne mémorise pas les opérandes des µops, donc pas d'adresse. Cela implique la lecture après émission, ce qui veut dire que l'adresse à lire/écrire est lue depuis les registres une fois que la µops sort de la file de µops (idem pour la donnée à écrire). En pratique, cette implémentation rend difficile de faire les calculs d'adresse ailleurs que dans l'unité mémoire. Heureusement, il est possible d'adapter la file de µops pour gérer la lecture avant émission. L'idée est qu'elle mémorise, pour chaque µop mémoire, l'adresse à lire/écrire et d'autres informations. Il ne s'agit donc plus vraiment d'une file de µops mémoire mais quelque chose d'autre, une sorte d'intermédiaire entre une station de réservation et une file de µops. D'un côté, elle fonctionne comme une file de µops car elle émet les µops dans l'ordre du programme. De l'autre, elle mémorise les opérandes des µops mémoire, ce qui en fait une pseudo-station de réservation. Une telle structure est appelée une '''''Load/Store Queue''''' (LSQ). Une LSQ contient plusieurs entrées, chaque entrée pouvant accueillir une µops mémoire et ses opérandes. Une entrée contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures. Les deux derniers champs sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. Elle contient aussi des bits de validité, qui indique si l'adresse ou la donnée sont disponibles, ou si le champ associé est vide. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2|Load Store Queue unifiée]] La LSQ doit faire le lien entre une adresse calculée par l'ALU entière, et l'entrée associée de la LSQ. Pour cela, l'unité d'émission peut associer un '''identifiant LSQ''' à une µops mémoire. L'identifiant est généré à l'émission. Aussi bien la µops de calcul d'adresse que a µop mémoire ont toutes deux le même identifiant. L'identifiant est propagé avec la µop de calcul d'adresse, comme tout signal de commande. En sortant de l'AGU, l’identifiant est transmis à la LSQ en même temps que l'adresse. La LSQ regarde quelle entrée a cet identifiant, et mémorise l'adresse dedans. La gestion des données à écrire se fait elle avec le système de contournement normal. La LSQ sait dans quel registre se trouve la donnée à écrire, elle surveille juste le système de contournement pour détecter quand la donnée est disponible. ===Le contournement des adresses mémoire avec une LSQ=== Avec une LSQ, les registres sont lus quand la µop mémoire est insérée dans la LSQ, si elles sont disponibles. Si elles ne sont pas encore disponibles, elles seront envoyées par le système de contournement plus tard. Cela implique qu'il y ait un système de contournement, qui relie les ALUs entières à la LSQ. Les AGUs envoient leur résultat à la LSQ, qui place alors l'adresse calculée dans la bonne entrée, la bonne "µop". [[File:Load-Store Queue.png|centre|vignette|upright=2|Load-Store Queue]] Une autre solution utilise des ALU spécialisées dans les calculs d'adresse, appelées des ''Adress Generation Unit'', abrévié AGU. L'avantage est que cela simplifie grandement le réseau de contournement : seules les AGU sont connectées à la LSQ. Un autre avantage est lié à l'adressage de la destination. En utilisant des opérations entières usuelles, il faudrait préciser que le résultat est à destination de la LSQ, pour configurer le réseau de contournement. Avec des AGU, les calculs d'adresse sont des micro-opérations différentes des additions et soustraction, elles sont envoyées au AGU spécifiquement, pas besoin de préciser la destination. [[File:ALU et LSQ.png|centre|vignette|upright=2|AGUs et Load-Store Queue.]] Dans les deux cas, les µops mémoire sont alors considérées comme des opérations entières un peu spéciales, gérées par le système d'exécution dans le désordre. Elles peuvent être émises dans le désordre, mais la LSQ se chargera de faire les accès mémoire dans l'ordre. Il y a donc calcul des adresses dans le désordre, mais accès mémoire dans l'ordre. ==Les ''schedulers'' mémoire unifiés== Avec la technique précédente, les µops mémoire s'exécutent dans l'ordre, à savoir que l'on ne peut pas déplacer une lecture avant ou après une autre. Il arrive que des lectures prêtes soient mises en attente, parce qu'une lecture précédente attend ses opérandes. Et ce retard sur les lectures se répercute sur les instructions lecture-dépendantes, donc sur le reste du pipeline.Les conséquences sur les performances sont assez importantes. Mais avec l'implémentation qui va suivre, il est possible exécuter les lectures dans le désordre tant que les conditions d'''aliasing'' sont respectées. L'implémentation remplace les files de µops mémoire par un ''scheduler'' spécialisé dans les µops mémoire. Il sera appelé le '''''scheduler'' mémoire''' dans ce qui suit. [[File:Processeur avec émission dans le désordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans le désordre des accès mémoire]] Une entrée du ''scheduler'' mémoire contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures (inutile pour les écritures). Elle contient aussi des bits de validité pour chaque champ qui indiquent si l'adresse d'accès est connue, ou si la donnée à écrire est disponible pour une écriture. Les champs pour la donnée à écrire ou le registre de destination sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2.5|''Load/Store Queue''.]] Lorsqu'une micro-opération mémoire est émise, elle est insérée dans le ''scheduler'' mémoire. Si l'adresse à lire/écrire est disponible, elle est immédiatement lue depuis les registres, idem pour une éventuelle donnée à écrire. Sinon, la micro-opération attend dans le ''scheduler'' mémoire que son adresse soit calculée par le processeur. ===Les calculs d'adresse avec un ''scheduler'' mémoire=== Le ''scheduler'' mémoire détecte des dépendances d'adresse entre instructions. Pour cela, il compare toutes les adresses à lire/écrire. Il mémorise donc les dites adresses, ce qui en fait une station de réservation. Et cela implique que ces adresses ont été calculées ailleurs, dans une ALU entière, ou dans une AGU séparée de l'unité mémoire. L'implémentation la plus simple effectue les calculs d'adresse dans les ALU entières. La sortie de toutes les ALU entière est reliée au ''scheduler'' mémoire. [[File:Contournement pour la file de µops mémoire.png|centre|vignette|upright=2|''scheduler'' mémoire]] Une autre implémentation utilise des unités de calcul d'adresse dédiées, afin de simplifier le réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse via AGU.png|centre|vignette|upright=2|ALU et ''scheduler'' mémoire]] Une conséquence est que l'implémentation impose que toutes les instructions mémoire sont découpées en deux µops : une µop pour calculer l'adresse, et une µop mémoire. C'est un défaut assez important, car une instruction mémoire prendra deux places dans le ROB, dans les stations de réservation, etc. Vu que les instructions mémoire sont très fréquentes, la capacité du ROB/autres est alors fortement réduite. Il faut noter que les adresses calculées dans l'ALU/AGU doivent atterrir dans l'entrée adéquate, celle de la µops qui doit recevoir l'adresse. La seule solution est que le ''program counter'' de la µops est transmis dans le pipeline, propagé avec la µops de calcul d'adresse. Quand le résultat est calculé, le ''program counter'' associé est envoyé avec au ''scheduler'' mémoire. Le ''scheduler'' mémoire enregistre alors l'adresse dans l'entrée qui est taguée avec cette adresse. ===La détection des dépendances mémoire=== Le ''scheduler'' mémoire intègre un système de détection des dépendances, qui est bien différent de celui d'une station de réservation normale. Les ''schedulers'' mémoire comparent non pas des noms de registres, mais des adresses/opérandes. Pour détecter les dépendances, le ''scheduler'' mémoire compare les adresses à lire/écrire. Pour les µops dont l'adresse est inconnue, le cas dépend de l'implémentation. La version la plus conservatrice suppose qu'elle a des dépendances possibles avec toutes les autres. Chaque micro-opération en attente compare son adresse avec celle de toutes les micro-opérations précédentes. Ou presque, la comparaison ne doit tenir compte que des écritures, et de celles situées avant dans l'ordre du programme. Le ''scheduler'' mémoire doit donc mémoriser les instructions dans l'ordre du programme, tout en faisant des comparaisons d'adresse. Ça en fait une sorte hybride entre mémoire associative et mémoire FIFO. Nous avions vu dans le chapitre "L'exécution dans le désordre" qu'il y a plusieurs manières pour implémenter une station de réservation : avec une mémoire associative, avec préplanification, avec des matrices de dépendances. Le problème est qu'on ne peut pas utiliser une mémoire associative pour le ''scheduler'' mémoire, qui utilise des comparateurs pour comparer les noms de registres/adresses. En effet, la détection des dépendances mémoire demande de faire beaucoup de comparaisons : chaque adresse est comparé à toutes les autres. Et les adresses faisant facilement 32 à 64 bits, les comparateurs sont bien plus imposants que ceux qui comparent des noms de registres de 5-10 bits. Le cout en matériel serait trop élevé, de même que le temps de calcul des dépendances. À la place, on utilise des matrices de dépendances. La détection des dépendances mémoire utilise précisément deux matrices de ce type : une matrice de disponibilité, et une matrice de dépendance. Ces matrices forment une espèce de tableau carré, organisé en lignes et en colonnes. L'instruction dans la énième entrée se voit attribuer la énième ligne et la énième colonne. À l'intersection d'une ligne et d'une colonne, on trouve un bit qui indique si l'instruction de la ligne et celle de la colonne ont une dépendance. La '''matrice de disponibilité''' permet de bloquer l'exécution des lectures/écritures tant qu'une instruction précédente n'a pas calculé son adresse, tant qu'on ne peut pas vérifier les dépendances. Lorsqu'une instruction est ajoutée dans le ''scheduler'' mémoire, elle met tous les bits de la colonne attribuée à 1 si l'adresse à lire/écrire est inconnue, puis les met à 0 une fois l'adresse calculée. Une instruction ne peut pas démarrer si, sur la ligne qui lui est attribuée, se trouve un 1. Plus précisément, si on trouve un 1 pour les instructions plus anciennes, ce qui fait qu'une partie de la matrice est ignorée. Les bits notés X dans le tableau suivant sont ignorés, car ils précisent la disponibilité des opérandes d'un opérande ultérieure, ce qui est inutile. En clair, les calculs d'adresse modifient les colonnes, et on vérifie les lignes pour autoriser l'exécution. {|class="wikitable" |- ! Instructions mémoire dans la LSB, triées de la plus ancienne à la plus jeune ! 0 !! 1 !! 2 !! 3 !! 4 !! 5 !! 6 |- ! 0 | 1 || X || X || X || X || X || X |- ! 1 | 0 || 0 || X || X || X || X || X |- ! 2 | 0 || 1 || 0 || X || X || X || X |- ! 3 | 0 || 1 || 1 || 1 || X || X || X |- ! 4 | 0 || 0 || 0 || 0 || 1 || X || X |- ! 5 | 0 || 0 || 1 || 0 || 0 || 1 || X |- ! 6 | 1 || 1 || 1 || 1 || 1 || 1 || 0 |} Les '''matrices de dépendances''' fonctionnent sur le même principe, mais déterminent les dépendances mémoire. Lorsque le processeur a calculé une adresse, il la compare avec les adresses à lire/écrire déjà dans le ''scheduler'' mémoire. Il met alors à jour les bits de la colonne de la matrice de dépendance : le bit à l'intersection d'une ligne et d'une colonne est mis à 0 si les deux instructions n'ont pas de dépendances, à 1 si elles en ont une. Une instruction mémoire est exécutée si tous les bits de sa ligne sont à 0. Là encore, on ne tient compte que des bits pour les instructions plus anciennes, la matrice a encore une fois une forme triangulaire, comme pour la matrice de disponibilité. Une optimisation scinde le ''scheduler'' mémoire en deux structures, aux rôles légèrement différents. La première gère la disponibilité des opérandes, la seconde gère seulement les dépendances mémoire. La première est une station de réservation où les instructions sont mises en attente tant que leurs opérandes ne sont pas prêts. La seconde met en attente des micro-opérations dont les opérandes sont prêts, mais qui doivent attendre pour une autre raison : dépendance mémoire, cache occupé, etc. L'avantage est qu'on remplace une grosse station de réservation par deux plus petites, mais l'intérêt est surtout relié au système de détection des dépendances. Sans cette optimisation, on a deux matrices énormes reliées à une ''Load/Store Queue'' unique. Avec la séparation, la matrice de disponibilité est reliée uniquement à la première station de réservation, alors que la matrice de dépendance est reliée uniquement à l'autre. Les deux matrices sont donc plus petites, car reliées à deux stations de réservation mémoire plus petites. ==L'exécution spéculative des accès mémoires== Pour rappel, le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. Les données lues sont en effet, le départ d'une série d'instructions dépendantes, généralement des instructions arithmétiques, mais parfois d'autres lectures. Quand on manipule des structures de données, il n'est pas rare qu'une lecture lise un pointeur-adresse utilisé par une autre lecture. Les techniques de désambiguïsation mémoire que nous avons vu précédemment sont dites conservatrices, à savoir qu'elles ne changent pas l'ordre de deux instructions mémoire si elles ont une dépendance d'adresse. Elles exécutent une lecture le plus tôt possible, mais seulement si on sait que celle-ci n'aliase pas d'écritures précédentes. Et cela demande de connaitre les adresses des écritures précédentes dans l'ordre du programme. Si une lecture est précédée par une ou plusieurs écritures, les adresses à écrire doivent être connues. Il se peut en effet qu’une écriture dont on ne connait pas l'adresse aliase la lecture. La garantie est liée à la présence de la file de µops mémoire ou la file de µops STORE. Si une seule écriture a une adresse inconnue, elle reste dans la file de µops mémoire et bloque l'émission de toutes les autres micro-opérations mémoire, lecture comme écriture. Mais il existe des techniques dites spéculatives, qui modifient l'ordre des accès mémoire sans se soucier de leurs dépendances. Elles émettent les lectures dès que leurs opérandes sont prêts, sans se préoccuper de l'aliasing avec une écriture précédente. Pour éviter tout problème, le processeur vérifie si aucune dépendance mémoire n'est violée et corrige le tir dès qu'une violation est détectée. Le processeur vérifie s'il a fait une erreur de prédiction, en vérifiant les adresses à écrire ou lire. En cas d'erreur, le processeur fait comme lors d'une prédiction de branchement ratée : il remet le pipeline en ordre et ré-exécute les accès mémoire dans le bon ordre. Il s'agit d'un cas particulier d’'''exécution spéculative''', au même titre que la prédiction de branchement. Toutes les techniques de ce genre exécutent des lectures dans le désordre. L'avantage est que cela permet d'exécuter les lectures en avance. Vu que la donnée lue est l'opérande d'autres instructions, les exécuter en avance fait prendre de l'avance à un paquet d'instructions. Mais il faut comparer cet avantage au risque de mauvaise prédiction. Et aussi bizarre que cela puisse paraître, cela donne de bons résultats. Il faut dire que les situations où une lecture relit une donnée tout juste écrite sont rares : même pas 2 à 3 % des lectures. Les dépendances mémoire sont donc très rares, les cas d'''aliasing'' sont vraiment limités. En termes de cout en transistors, le hardware qui vérifie les dépendances mémoire reste globalement le même à peu de choses près, il est juste déplacé dans le pipeline et adapté à sa nouvelle fonction. ===La file de lectures=== La technique que nous allons voir émet les lectures dans l'ordre, ce qui simplifie énormément la gestion des dépendances. Les lectures sont émises dans l'ordre, les écritures aussi. Par contre, l'ordre entre lectures et écritures n'est pas respecté. Une lecture peut passer avant une écriture sans problèmes, voire avant plusieurs écritures d'un seul coup. Une telle lecture exécutée en avance d'une autre écriture est appelée une '''lecture spéculative'''. Une lecture spéculative peut donc sauter devant une ou plusieurs écritures, qui seront appelées des '''écritures sautées'''. Une écriture sautée est une précédant une lecture spéculative, dans l'ordre du programme. Évidemment, il se peut qu'une écriture sautée écrive la donnée qui est lue par la lecture spéculative. Il y a alors une violation de dépendance mémoire. Mais le processeur suppose qu'il n'y a pas de dépendantes de ce type et émet la lecture en avance. Rassurez-vous, il surveille quand même la situation, pour tout annuler ensuite s'il détecte une violation de dépendance mémoire. J'ai dit plus haut que le processeur émet les lectures dans l'ordre, ainsi que les écritures dans l'ordre. Pour cela, la LSQ est scindée en deux files de µops : une pour les µops LOAD et une autre pour les µops STORE. Les cours d'architecture des ordinateurs nomment souvent ces deux files en utilisant les termes de ''file de STORE'' (''store queue'') et de ''file de LOAD'' (''load queue''), mais il y a un risque de confusion avec la file d'écriture vue précédemment, et la file de lecture qu'on introduira dans la suite. Dans ce qui suit, nous parlerons de '''file de µops LOAD''' et de '''file de µops STORE''', pour éviter toute confusion. Les deux files sont chacune connectées à l'unité mémoire, via un port dédié. L'unité mémoire a donc un port pour les lectures, et un port pour les écritures. Le port d'écriture est relié à la file d'écriture, le port de lecture est directement relié au cache de données. Il faut noter qu'une unité mémoire à deux ports n'est pas si bizarre que cela. En effet, le cache de données a lui aussi deux ports, dans la majorité des cas, avec un port de lecture et un port d'écriture. [[File:Load Ordering, Store Ordering.jpg|centre|vignette|upright=2|Exécution dans le désordre des lectures.]] Maintenant, voyons comment sont détectées les violations de dépendances mémoire. Les dépendances qui peuvent être violées dans ce cas de figure se font entre une écriture sautée et une lecture spéculative. Si une lecture spéculative lit une donnée qui est écrite par une écriture sautée, alors une dépendance RAW a été violée. Pour les détecter, le processeur garde une trace des lectures spéculatives; tant que les instructions précédentes ne sont pas terminées. Pour cela, il utilise une file d'attente, qui mémorise les lecture spéculatives. Nous l'appellerons la ''file de vérification des lectures'', ou encore la '''file de lectures'''. Elle est une sorte d'équivalent de la file d'écriture, mais pour les lectures. Les lectures sortent de la file µops LOAD dès que leurs adresses sont calculées, sans tenir compte des dépendances mémoires. Elles sont alors ajoutées à la file de lectures et s'exécutent immédiatement, de manière spéculative. Pour vérifier qu'il n'y a pas d'erreur de spéculation, le processeur conserve l'adresse de la lecture effectuée dans la file de lectures. Les lectures quittent la file de lecture quand toutes les instructions précédentes dans l'ordre du programme sont terminées, à savoir tant quand elles ont quitté le tampon de réordonnancement/ROB. Notons que la file de vérification des lectures contient les lectures dans l'ordre du programme. En effet, les lectures sont accumulées en sortant de la file de µops LOAD, où elles sont triées dans l'ordre du programme. Et cela permet de gérer les erreurs de prédiction parfaitement, en annulant les instructions suivant une erreur de prédiction. [[File:Spéculation avec une file de lecture.jpg|centre|vignette|upright=2.5|Spéculation avec une file de lecture.]] Reste à détecter les erreurs de prédiction, ce qui peut être fait avec deux méthodes. La première détecte les violations de dépendances mémoire à chaque écriture. L'idée est que chaque écriture vérifie si une lecture anticipée n'a pas lue la donnée écrite en avance. Pour cela, quand une écriture est émise, elle consulte la file de lecture pour voir s'il y a un conflit d'adresse. La vérification fait cependant gaffe à ne prendre en compte que les lectures situées après l'écriture dans l'ordre du programme. Précisément, elle envoie l'adresse à écrire à la file de lecture et cherche si une lecture ultérieure a eu lieu à la même adresse. Si c'est le cas, cela signifie qu'une lecture anticipée aurait du lire la donnée actuellement écrite, mais que la lecture a été exécutée trop en avance. La lecture n'a pas lu la bonne donnée, il y a eu une violation de dépendance mémoire. Pour faire cette vérification, la file de lecture est un mélange entre FIFO et cache/mémoire associative, sur le même modèle que la file d'écriture. La différence est que la consultation de la file de lecture ne sert pas à faire du contournement, mais à détecter les dépendances. Quand une dépendance est détectée, l'écriture se fait normalement. Par contre, la lecture marquée comme invalide dans la file de lecture. L'exécution du programme se poursuit, jusqu'à ce que la lecture arrive à l'étape de ''commit'', à savoir que le ROB lui dit que les instructions précédentes sont terminées sans encombre. Là, la lecture étant invalide, le processeur déclenche une procédure de réparation, qui vide partiellement le pipeline de toutes les instructions qui suivent la lecture. La lecture est de plus ré-executée, afin de lire la bonne donnée. Avec la seconde méthode, les violations de dépendance mémoire sont détectées au moment où la lecture quitte à la file de lectures et le ROB. L'idée est de comparer la donnée lue de manière spéculative avec la donnée lue de manière non-spéculative. Pour faire la comparaison, la lecture spéculative copie la donnée lue dans la file de lecture. Quand la lecture quitte la file de lecture et le ROB, la lecture est ré-exécutée et la donnée relue. Le processeur compare alors la donnée lue à ce moment avec la donnée mémorisée dans la file de lecture. Si la donnée est différente, alors il y a eu erreur de violation de dépendance mémoire. Évidemment, relire une donnée n'est pas gratuit. Il est possible de limiter son impact en performance en dédiant un port de lecture sur le cache rien que pour ça. Mais le cout en circuit est comparable à l'usage d'une FIFO associative de la technique précédente. Mais quoi qu'il en soit, le cache doit permettre des lectures en un seul cycle, sans quoi la méthode ne marche pas vraiment. ===La prédiction de dépendances mémoires=== Certains processeurs essayent de prédire si deux accès mémoires sont dépendants : ils incorporent une unité qui va fonctionner comme une unité de prédiction de branchement, à la différence qu'elle prédit les dépendances mémoires. Si cette unité prédit que deux accès mémoires sont indépendants, le processeur les exécute dans le désordre, et les exécute dans l'ordre du programme dans le cas contraire. Il faut prendre en compte les erreurs de prédiction, ce qui est fait comme pour les mauvaises prédictions de branchement : on vide le pipeline. Beaucoup de techniques de prédiction des dépendances mémoire ont été inventées et en faire une revue exhaustive serait long et fastidieux. On pourrait citer les ensembles colorés (''color sets''), et bien d'autres techniques. Mais nous n'allons parler que des techniques les plus élémentaires. Quoi qu’il en soit, la recherche sur le sujet est assez riche, comme toujours quand il s'agit d'architecture des ordinateurs. On peut améliorer cette technique en mémorisant les instructions qui ont causé une mauvaise prédiction de dépendance mémoire. La prochaine fois qu'on exécute ces instructions, on sait qu'il y a de grandes chances qu'il se produise une erreur de prédiction, ce qui pousse à ne pas les exécuter de manière spéculative. On peut pour cela réutiliser les techniques de prédiction de branchement, tels des compteurs à saturations, mis à jour en cas d'erreurs de prédiction de dépendances mémoire. Dans tous les cas, on trouve un cache, équivalent au ''branch target buffer'', qui mémorise les instructions fautives (leur ''Program Counter''), avec d'autres informations comme les adresses des instructions dépendantes. La première classe de techniques du genre consiste à mémoriser les lectures qui ont causé une violation de dépendance, tandis que l'autre ne mémorise que les écritures. Cette dernière est l'approche utilisée par le '''cache de barrières d’écriture''' (store barrier cache). Enfin, nous allons conclure avec une dernière technique : celle des '''ensembles d’écritures''' (store sets). Cette technique est capable de gérer le cas où une lecture dépend de plusieurs écritures : le processeur mémorise l'ensemble des écritures avec lesquelles la lecture a déjà eu une dépendance. Cet ensemble d'écritures associées à une lecture est appelé tout simplement un ensemble d’écritures, et reçoit un identifiant (un nombre) par le processeur. Cette technique demande d'utiliser deux tables : * une qui assigne un identifiant d’ensemble d’écritures à l'instruction en cours ; * une autre qui mémorise les ensembles d’écritures sous la forme d'une liste chainée : le début de la liste correspond à l'écriture la plus ancienne de l’ensemble. Cela permet d'obtenir l'écriture la plus ancienne dans cet ensemble d’écritures directement. Quand le processeur détecte une violation de dépendance entre une lecture et une écriture, elle ajoute l'écriture dans l’ensemble d’écritures adéquat. Le déroulement d'une lecture demande d'accéder à la première table pour récupérer l'identifiant de l’ensemble d’écritures, et l'utiliser pour adresser la seconde table. S'il y a dépendance, cet accès renvoie l'écriture fautive en cas de dépendance. Quand une écriture est envoyée à l'unité mémoire, celle-ci va accéder à la table de correspondances de la même manière qu'une lecture, et va récupérer l'identifiant de l’ensemble d’écritures auquel elle appartient, identifiant qui est utilisé pour vérifier s'il y a une écriture en cours d’exécution. Si c'est le cas, cela signifie que l'écriture en cours d’exécution doit s’exécuter avant l'écriture qui a consulté la table : cette dernière est mise en attente. ==La prédiction d'adresse et de valeur== D'autres techniques de prédiction mémoire plus élaborées que les précédentes existent. Il s'agit de la prédiction d'adresse et de la prédiction de valeur. Leur nom trahit ce qu'elles font. La première technique tente de prédire l'adresse d'une lecture/écriture, alors que la seconde tente carrément de prédire quelle sera la donnée lue. Il y a bien de la recherche académique sur le sujet, mais il s'agit de techniques assez osées, dont on voit mal comment elles pourraient fonctionner. L'efficacité de cette technique a été étudiée grâce à des simulateurs. Suivant les études ou les programmes, on trouve des résultats qui varient pas mal. Dans certains cas, la performance baisse un peu, et dans d'autres, on peut avoir des gains de plus de 10% ! Mais dans des cas normaux, on trouve des gains de 4-5% environ. Ce qui est tout de même pas mal à l'heure actuelle. Cependant, en Janvier 2025, à l'heure où j'écris ces lignes, il a été découvert la première utilisation de ces deux techniques dans un processeur commercial. La découverte fait suite à la publication de deux vulnérabilités matérielles sur les CPU Apple M2 et M4 et quelques CPU ARM, qui font justement usage de ces deux techniques. Les deux vulnérabilités, appelées SLAP et FLOP, sont assez similaires aux attaques Spectre et Meltdown, sauf qu'elles attaquent non pas la prédiction de branchement, mais la prédiction d'adresse et de valeur du CPU. ===La prédiction d'adresse=== La prédiction d'adresse tente de prédire l'adresse d'une lecture à l'avance, ce qui permet de lancer les lectures en avance. Si la prédiction est correcte, on gagne quelques cycles qui permet aux unités de calcul de faire leurs calculs plus en avance, sans compter que les mécanismes de désambiguïsation mémoire fonctionnent plus efficacement. Si jamais cette adresse est connue plus tôt, on détecte les dépendances plus rapidement et on peut agir en conséquence. Des variantes de ces unités de prédiction d'adresse sont utilisées pour précharger des données depuis le cache de donnée. Et leur efficacité est assez bonne, voire excellente dans certains cas. La différence est que la prédiction d'adresse est utilisée pour prédire les adresses à lire depuis le cache vers les registres, ce qui est le sujet de cette sous-partie. La prédiction d'adresse est réalisée par une sorte de cache, qui associe à chaque instruction la prochaine adresse à lire. Nous appellerons ce cache la '''table de prédiction d'adresse'''. Pour faire l'association instruction <-> adresse lue/écrite, le tag du cache utilise l'adresse de l'instruction (le ''program Counter''), alors que la ligne de cache associée contient l'adresse prédite. Son contenu est utilisé pour faire une prédiction. Lorsqu'une lecture est émise, la table est consultée pour récupérer en avance l'adresse à lire, avant que celle-ci soit calculée par l'ALU. La lecture est alors exécutée en avance, avant que la vraie adresse soit connue, en utilisant l'adresse prédite. Reste qu'il faut des circuits autour de cette table de prédiction d'adresse, pour déterminer si la prédiction est correcte, ainsi que pour remplir cette table avec une adresse prédite. Pour cela, il y a un circuit de prédiction d'adresse dédié. Il récupère les instructions qui quittent le ROB ou la file des lectures mémoire, afin de vérifier les lectures qui viennent de se terminer/''commit'' et qui quittent le pipeline. Il peut contenir une '''table d'apprentissage''', qui mémorise des informations nécessaires pour faire des prédictions d'adresse. Par exemple, un historique des dernières adresses lues pour les lectures les plus récentes, ou un historique des adresses des lectures. La table est aussi utilisée pour déterminer si les prédictions effectuées étaient correctes ou non. Pour cela, il est possible de réutiliser les techniques vues dans le chapitre sur la prédiction de branchement, notamment des compteurs à saturation. Pour la prédiction de branchement et les autres formes de prédiction mémoire, les table d'apprentissage et pour les instructions/adresses sont fusionnées en une seule. Mais ici, il est nécessaire de les séparer pour une raison assez particulière : éliminer la pollution du cache dans la table de prédiction d'adresse. On ne peut pas allouer une ligne de cache pour chaque nouvelle instruction de lecture exécutée. Seule une minorité des lectures profitent de la prédiction d'adresse. Il est nécessaire de filtrer les lectures pour ne conserver que celles qui profitent de la prédiction d'adresse, ce qui est possible plus facilement en séparant la table d'apprentissage et les compteurs à saturation de la table de prédiction d'adresse. Une première méthode consiste à prédire que la lecture lit toujours la même adresse, ce qui lui vaut le nom de '''prédiction d'adresse constante'''. On pourrait se demander pourquoi une telle situation surviendrait. La raison est qu'il arrive que les compilateurs n'arrivent pas à gérer les accès mémoires efficacement. Dans certaines situations un peu compliquées impliquant des pointeurs ou des références, divers phénomènes complexes d'''aliasing'' des pointeurs peuvent générer des relectures intempestives de données en mémoire. Cela peut aussi arriver lorsqu'on compile du code qui provient de plusieurs librairies, ou quand du code lit régulièrement des constantes en mémoire ROM. La prédiction d'adresse constante ajoute un circuit qui analyse les dernières lectures, une fois qu'elles quittent le ROB, qu'elles ''commit''. Si, pour une instruction de lecture, l'adresse lue est toujours la même, alors l'instruction est ajoutée dans la table de prédiction d'adresse. Vu que les instructions qui accèdent toujours à la même adresse sont rares, il est préférable d'initialiser les compteurs à saturation de façon à ce qu'ils disent que toute nouvelle instruction change d'adresse. Il s'agit d'une méthode simple mais peu efficace, car cette situation est rare. Une autre méthode détecte les accès en enjambées, là encore quand les lectures quittent le ROB. Nous l’appellerons la '''prédiction d'adresse en enjambées'''. De tels accès surviennent régulièrement quand on accède des tableaux ou d'autres structures de données semblables, ce qui rend la technique très intéressante. Implémenter cette technique est facile : il suffit d'ajouter un circuit qui détecte les accès en enjambées, au lieu de celui qui détecte les accès constants. À chaque fois qu'une lecture entraine un succès de cache dans la table de prédiction d'adresse, l'enjambée est ajoutée à l'adresse contenue dans la table de prédiction d'adresse, l'ancienne adresse prédite est remplacée par celle avec l'enjambée d'ajoutée. Sur les Apple M2-M4, la prédiction d'adresse gére aussi bien les adresses constantes que les accès en enjambée. Le brevet qui décrit l'implémentation de l'unité de prédiction d'adresse et de valeur est potentiellement le suivant : [https://patents.google.com/patent/US11829763B2/en Early load execution via constant address and stride prediction]. En théorie, il est possible de faire mieux en repérant des accès mémoires qui se répètent de façon régulière et cyclique, même s'ils n'ont aucune enjambée. Ce genre d'accès se trouve assez souvent lorsque l'on manipule des listes chainées ou des structures de données assez irrégulières comme des arbres, des graphes, etc. Mais il n'y a pas encore de CPU qui implémente de telle optimisation. ===La prédiction de valeur : prédire la donnée lue=== Les techniques de '''prédiction de valeur''' (''Value Prediction'') consistent à prédire quelle est la donnée qui sera lue par une instruction de lecture. Oui, vous avez bien lu : le processeur est capable de parier sur la valeur qui sera chargée depuis la mémoire, de prédire si cette valeur vaut 0, 1, 1024, etc. Une fois son pari fait, il exécute les instructions du programme avec la valeur qu'il a pariée de façon spéculative. Si le pari est correct, le CPU continue l’exécution. Sinon, il est obligé de vider le pipeline et de recommencer avec la bonne valeur. Au premier abord, cette technique semble franchement mal partie tellement les chances de se tromper sont tellement énormes ! Mais le fait est que, dans certains cas, il est possible de spéculer correctement. Là encore, les techniques les plus simples fonctionnent dans deux cas. Le premier est celui où une donnée est relue à l'identique à chaque fois que la lecture est exécutée. Le second est celui où la donnée est incrémentée/décrémentée d'une constante qui est toujours la même d'une exécution de la lecture à l'autre. Une sorte d'enjambée constante, mais pour les données. Nous allons nous concentrer sur le premier cas, celui où une donnée est relue plusieurs fois sans être modifiée entre temps. L'idée de base est qu'une instruction de lecture qui s'exécute plusieurs fois de suite à la même adresse renvoie la même valeur à chaque fois. C'est parfaitement possible s’il n'y a eu aucune écriture à cette adresse entre-temps, d'où le fait qu'il faille surveiller si les prédictions constantes sont correctes. On peut se demander quelles sont les raisons qui font qu'une instruction de lecture renvoie la même valeur à chaque fois. Après tout, autant lire une seule fois la donnée et la garder dans un registre une bonne fois pour toutes ! Mais dans la réalité, on fait face à quelques limites, qui seront détaillées dans la liste suivante. La première est que le manque de registre fait que certaines données sont conservées en RAM et sont relues fréquemment. Cela arrive quand on stocke des constantes en mémoire. Par exemple, sur les processeurs x86, les constantes flottantes ne peuvent pas être intégrées dans nos instructions via le mode d'adressage immédiat. À la place, on les stocke en mémoire et on les charge dans les registres à chaque fois qu'on en a besoin. L'implémentation est similaire à la prédiction d'adresse, si ce n'est que c'est la donnée lue qui est prédite. Là encore, on a une '''table de prédiction de donnée lue''', consultée lorsqu'une lecture est émise. La table de prédiction de la donnée lue est une mémoire cache qui associe le ''Program Counter'' de la lecture à la donnée prédite, éventuellement couplé à des compteurs à saturation. Elle est consultée à chaque fois qu'une lecture est émise, la donnée adéquate est récupérée lors d'un succès de cache. On trouve aussi un circuit qui insère ou retire des instructions de la table de prédiction de donnée lue. Elle détecte les lectures prédictibles, couplé à une table d’apprentissage, et quelques circuits annexes liés au ROB ou aux files de lectures. Elle contient au minimum des compteurs à saturations, associés chacun à une instruction (son ''program counter'' pour être précis). ==L'optimisation du ''Mirroring memory operand''== Les processeurs modernes implémentent une optimisation concernant les accès mémoire. Cette optimisation a été décrite par Agner Fog dans ses manuels d'optimisation, sous le terme de '''''Mirroring memory operand'''''. Il a documenté son apparition sur les processeurs Zen 2 d'AMD, puis son évolution sur les processeurs Zen 4 et 5. L'optimisation est bizarrement absente de la microarchitecture Zen 3. Elle se manifeste dans une condition très précise : plusieurs instructions proches accèdent à la même adresse. Les deux instructions ne sont pas forcément consécutives, il peut y avoir une dizaine ou centaine d'instructions entre les deux. Une telle situation survient souvent sur les CPU CISC, et notamment sur les CPU x86, car ils ont peu de registres. les compilateurs font donc beaucoup d'échanges entre pile d'appel et registres, ce qui fait que des données écrites dans la pile sont souvent relue quelques instructions plus tard. Dans ce cas, la donnée lue/écrite est mémorisée dans un registre interne au processeur et peut être accédée très rapidement. Par exemple, imaginons plusieurs lectures proches à la même adresse, sans écriture entre elles. Dans ce cas, le processeur ne fera qu'une seule lecture en mémoire RAM, les lectures ultérieures liront la donnée depuis le registre interne. Comme autre exemple, imaginons une écriture suivie plus tard par une lecture à la même adresse. Dans ce cas, la donnée sera écrite dans un registre interne, en plus d'être propagée dans le cache ou la RAM, et la lecture ultérieure lira directement le registre interne. Le gain en performance est alors conséquent, comparé à du simple ''store-to-load forwarding''. Il faut noter que la technique marche aussi pour les opérations ''load-op'', pas seulement pour les instructions d'accès mémoire. Par exemple, prenons une écriture en RAM, suivies quelques instructions plus tard par une opération ''load-op'' à la même adresse. L'écriture écrit à une adresse, qui est lue par l'instruction ''load-op''. L'optimisation s'applique : l'instruction ''load-op'' lira l'opérande mémoire depuis un registre interne du processeur, qui contient une copie de la donnée écrite. Sur les processeurs Zen 2, l'optimisation marchait aussi pour les instructions PUSH et POP, qui lisaient/écrivaient dans la pile d'appel. Mais cette possibilité a été retirée sur les processeurs Zen 5. La technique détecte que deux instructions accèdent à la même adresse sous certaines conditions bien précises. L'optimisation ne marche que pour les modes d'adressages indicés et indirect, avec ou sans décalages, mais pas en mode absolu ou relatifs. Il faut préciser que l'optimisation ne marche que pour les registres généraux, pas les registres flottants. Les registres doivent être précisés de la même manière dans toutes les instructions. Par exemple, le processeur ne reconnait pas que l'addition des deux registres RCX + RAX est la même que RAX + RCX. Sur les CPU Zen 2 et 4, elle ne marchait que pour des opérandes de 32 et 64 bits, mais les processeurs Zen 5 gèrent des données de 8 et 16 bits aussi. Les décalages étaient limités à 8 bits sur le Zen 2, mais n'a plus de limites sur les Zen 4 et 5. ==La désambiguïsation mémoire a des effets de bord== La désambiguïsation mémoire a une caractéristique que l'exécution dans le désordre simple n'a pas : elle a des effets qui débordent sur la mémoire. Il est possible de savoir si un processeur fait de la désambiguïsation mémoire en regardant le bus mémoire. On s’aperçoit alors que les lectures se font dans un ordre différent de celui du programme. À l'opposé, l'exécution dans le désordre n'a pas d'effets observables de ce genre. Elle améliore la performance, elle modifie les durées observables des instructions, mais cela se limite à des questions de ''timings''. Le seul moyen de savoir si un processeur fait de l'exécution dans le désordre est de mesurer les latences/durées de chaque instruction. Mais aucun état observable au-delà des ''timings'' n'est modifié. Pas avec la désambiguïsation mémoire. ===La consistance mémoire=== Le fait que la désambiguïsation mémoire ait des effets observables ne pose aucun problème avec un seul processeur, ca elle est conçue pour. Par contre, les techniques de désambiguïsation mémoire peuvent poser des problèmes avec plusieurs processeurs. Il arrive que des programmes partagent une même portion de mémoire, et lisent/écrivent dedans. Avec plusieurs processeurs, chaque processeur effectue la désambiguïsation mémoire dans son coin sans se préoccuper des autres. Les opérations d'écriture peuvent être mises dans le désordre du point de vue des autres processeurs, et les lectures effectuées par les autres processeurs peuvent alors renvoyer de vielles données. Un exemple classique est celui de deux cœurs qui partagent le même cache ou la même mémoire, mais qui utilisent chacun une file d'écriture (''store buffer''). Imaginons que le premier cœur effectue une écriture, puis une lecture, à des adresses différentes. L'écriture est retardée grâce au tampon d'écriture, alors que la lecture lit directement le cache. Pour le premier processeur, l'écriture s'est réalisée avant la lecture, car pour lui, la fin de l'écriture signifie écrire dans le tampon d'écriture. Mais pour le second cœur, l'ordre s'est inversé : il voit la lecture dans le cache, puis l'écriture dans le cache. La raison est qu'il n'a pas connaissance du contenu du tampon d'écriture de l'autre cœur. Un autre exemple : les techniques de prédiction d'adresse et de valeur posent des problèmes avec les dépendances RAW. Elles permettent à une instruction mémoire de s'exécuter avant une autre, malgré la présence d'une dépendance RAW entre les deux. L'ordre entre les deux instructions change, alors que les autres processeurs ne devraient pas le voir. Et dans les faits, les processeurs qui incorporent ces techniques ont des modèles de consistance mémoire qui ne permettent pas ces optimisations en cas de dépendances RAW. Les deux techniques doivent idéalement être désactivées pour les instructions mémoire qui ont des dépendances RAW. Les programmeurs doivent tenir compte de ce genre de situations, dans des cas assez rares et liés à la programmation bas niveau. Pour cela, ils doivent savoir comment le processeur réorganise les accès mémoire. Concrètement, chaque processeur définit un '''modèle de consistance mémoire''' qui indique quelles optimisations sont activées et non. Il s'agit d'une sorte de contrat entre le logiciel et le matériel : les programmeurs savent comment le processeur va réorganiser les lectures et écritures et peuvent coder leurs programmes en fonction. L'implémentation du système de consistance mémoire dépend grandement de l'architecture, mais des modèles ont été standardisés afin de permettre aux programmeurs de savoir où placer les barrières mémoires. Il s'agit de vrais standards, formalisés, parfois mathématiquement, que des chercheurs étudient sur un plan mathématique et algorithmique. Le modèle préféré des programmeurs est la '''consistance séquentielle''', où les différents processeurs effectuent des accès mémoire dans l'ordre du programme et les accès mémoire simultanés sont interdits. La première contrainte interdit l'usage de mécanismes de désambiguïsation mémoire, la seconde interdit l'usage de caches non-bloquants. La consistance séquentielle a donc un cout en performance assez important, ce qui fait que les processeurs modernes n'utilisent pas la consistance séquentielle. Un modèle plus flexible est le '''''total store ordering''''', qui autorise la présence d'une file d'écriture et l'usage de caches non-bloquants pour les lectures (interdit d'avoir plusieurs écritures simultanées). C'est plus ou moins celui utilisé par les processeurs x86, comme on le verra plus bas. Il existe des modèles de consistance mémoire plus relâchés, appelés des '''modèles de consistance faible''', qui autorisent diverses optimisations de spéculation mémoire, la combinaison d'écriture (fusionner deux écritures à la même adresse en une seule), etc. Ils sont supportés sur les architectures ARM et POWER. ===Les barrières mémoire=== Le changement d'ordre des lectures et écritures peut poser occasionnellement des problèmes avec la programmation pour les architectures multi-processeur ou multicœurs. Il existe des morceaux de code qui ne marchent que si la consistance séquentielle est respectée et il faut faire en sorte qu'ils marchent sur des processeurs avec un modèle de consistance mémoire différent. Pour cela, les processeurs incorporent des instructions appelées '''barrières mémoires''' ou '''''Fences'''''. Elles forcent le processeur à terminer tous les accès mémoire qui précédent dans l'ordre du programme. Certains processeurs possèdent une barrière mémoire pour les lectures et une autre pour les écritures. D'autres processeurs ajoutent des barrières mémoires dédiées à des cas particuliers. Elles permettent de forcer les accès à des données partagées à dérouler correctement. [[File:Fences.png|centre|vignette|upright=2|Fences.]] Leur implémentation est assez simple : elles empêchent l'émission de toute nouvelle instruction mémoire, tant qu'une condition précise n'est pas réunie. Par exemple, la barrière mémoire pour les écritures attend que la file d'écriture soit vidée avant de démarrer le moindre accès mémoire. Un compilateur peut placer les barrières mémoires au bon endroit, avec un peu d'aide du programmeur. Certains langages de programmation permettent d'indiquer au compilateur qu'une donnée doit toujours être lue depuis la mémoire RAM, via le mot-clé volatile. C'est très utile pour préciser que cette donnée est potentiellement partagée par plusieurs processeurs ou manipulable par des périphériques. Les compilateurs peuvent placer des barrières mémoires lors des lectures ou écritures sur ces variables, mais ce n'est pas une obligation. Il arrive aussi que le programmeur doive manipuler explicitement des barrières mémoires. Utiliser l'assembleur est alors une possibilité, mais qui est rarement exploitée, pour des raisons de portabilité. Pour limiter la casse, certains systèmes d'exploitations ou compilateurs peuvent aussi fournir des barrières mémoires explicites, encapsulées dans des bibliothèques ou cachées dans certaines fonctions. ===La modèle de consistance des processeurs x86=== Après avoir vu la théorie, passons maintenant à la pratique. Dans cette partie, on va voir les modèles de consistances utilisés sur les processeurs x86, ceux qu'on retrouve dans les PC actuels. Le modèle de consistance des processeurs x86 a varié au cours de l'existence de l'architecture : un vulgaire 486DX n'a pas le même modèle de consistance qu'un Core 2 duo, par exemple. Ils ont évolués au cours du temps, avec l'arrivée de nouvelles optimisations. De plus, leur formalisation a été progressive, et s'est faite au fur et à mesure. En général, les modèles de consistance des processeurs x86 ont toujours étés assez restrictifs, si on les compare aux autres processeurs. Il s'agit de modèles de type ''total store ordering'' (TSO), au moins dans les grandes lignes. Ils permettent donc d'effectuer certaines lectures en avance, soit parce que les lectures se font à des adresses différentes, soit par utilisation de réacheminement écriture vers lecture. Les possibilités d'exécution en avance des lectures varient suivant le processeur, elles deviennent de plus en plus performantes avec le temps. Le premier modèle de consistance est apparu sur les premiers processeurs x86 et est resté en place sur tous les processeurs de marque Pentium. Les processeurs de l'époque n'incorporaient pas beaucoup d'optimisations, le budget en transistor n'était pas assez conséquent pour ça, la mémoire n'était pas assez lente. Le modèle TSO de l'époque n'autorisaient que peu de lectures anticipées. Une lecture ne peut être exécutée en avance que sous les conditions suivantes : * les écritures contournées se font dans la mémoire cache ; * la lecture doit se faire dans la mémoire RAM ; * les écritures se font à une adresse différente de la lecture ; * aucune transaction avec un périphérique ne doit être en cours. A partir du Pentium 4, les choses changent. Le Pentium 4 est en effet le premier processeur à implémenter des techniques permettant d’exécuter plusieurs processus en parallèle, dont l'Hyperthreading. En conséquence, le modèle de consistance a dû être assoupli pour éviter de perdre bêtement en performance. Il s'agit d'un vrai modèle de type TSO, la majeure partie des contraintes sur les lectures anticipées sont réduites au minimum. Il y a aussi des contraintes supplémentaires pour les instructions dites atomiques, ainsi que pour les instructions qui accèdent aux périphériques ou aux entrées-sorties. Une optimisation séparée du TSO est que les écritures en mémoire peuvent changer d'ordre dans un cas exceptionnel : les écritures effectuées par les instructions de copie mémoire comme REPMOVSD, REPSCACB, ainsi que les instructions SSE MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, MOVNTPD. Les écritures effectuées dans ces instructions peuvent se faire dans un désordre complet (ou presque). Lors du passage au 64 bits, le modèle mémoire des processeurs x86 de l'époque a été formalisé dans un papier d'Intel appelé [https://www.cs.cmu.edu/~410-f10/doc/Intel_Reordering_318147.pdf Intel® 64 Architecture Memory Ordering White Paper], mis à jour en 2008 suite à quelques problèmes techniques. La formalisation du modèle a été d'une grande aide aux programmeurs, qui devaient se débrouiller avec des documentations qui ne formalisaient pas de modèle de consistance précis. Le document décrit un modèle de consistance mémoire portant le nom barbare de ''total lock order + causal consistency” (TLO+CC)'', qui admet plus d'optimisations que le modèle TSO. Mais la description était en réalité légèrement différente du modèle mémoire réellement implémenté sur les processeurs existants, qui utilisaient du TSO, sauf en de rares occasions. Les concepteurs de processeur font souvent cela, à savoir définir un modèle de consistance plus laxiste que réellement implémenté, pour des questions de comptabilité. Ainsi, si les futurs processeurs de la marque implémentent un modèle plus laxiste, les programmes déjà codés continuent de fonctionner. Le modèle décrit dans la documentation laisse de la marge pour le futur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le parallélisme mémoire | nextText=Le parallélisme mémoire }} </noinclude> 3p13mt2qynd67ifd5qy12wqz00k71dc 774134 774133 2026-10-04T13:48:05Z Mewtow 31375 /* L'exécution anticipée des calculs d'adresse */ 774134 wikitext text/x-wiki Dans les chapitres précédents, nous avons vu la mal-nommée exécution dans le désordre dans les chapitres précédents, qui devrait plutôt s'appeler l'émission dans le désordre. Elle modifie l'ordre des instructions pour gagner en efficacité, mais cela n'est valable que pour les instructions travaillant sur des registres ! Nous n'avons pas parlé de l'exécution dans le désordre des accès mémoire, car ils sont un peu à part. Et pour comprendre pourquoi, nous allons dédier ce chapitre à la gestion des accès mémoire dans le pipeline. Avant de poursuivre, faisons un rappel rapide sur les dépendances de données. Il faut distinguer deux types de dépendances de données : les dépendances de registres (deux instructions manipulent le même registre), et les dépendances d'adresse (deux instructions manipulent la même adresse). Deux instructions ont une dépendance d'adresse si elles écrivent ou lisent la même adresse mémoire. Des instructions mémoires qui lisent/écrivent des adresses différentes sont indépendantes. Lorsque deux instructions mémoire lisent/écrivent à la même adresse, on parle d''''aliasing mémoire'''. Formellement, l'aliasing mémoire n'est ni plus moins que l'application des dépendances de données aux instructions mémoire. Et comme les autres dépendances de données, il existe plusieurs types d'aliasing mémoire : RAR, RAW, WAR et WAW. Les dépendances RAR ne posent aucun problème, les lectures peuvent changer d'ordre sans aucun problème, tant qu'il n'y a pas d'écritures entre les deux. Par contre, les autres dépendances imposent trois contraintes. * Les dépendances RAW imposent que les lectures ne doivent pas s'exécuter avant une écriture à la même adresse. * Les dépendances WAR imposent que l'on ne peut pas déplacer une écriture avant une lecture à la même adresse. * Enfin, les dépendances WAW font que l'ordre des écritures doit être celui du programme : impossible de changer l'ordre de deux écritures. Les trois contraintes peuvent s'implémenter de plusieurs manières différentes. Comme pour les dépendances de données normales, seules les dépendances RAW sont de vraies dépendances de données. Les deux autres dépendances viennent du fait que l'on utilise la même adresse pour stocker des valeurs différentes. Détecter les cas d'''aliasing'' mémoire est primordial pour gérer la désambiguïsation mémoire, et cela demande de comparer des adresses. S'il existe des techniques similaires au renommage de registre permettent de les faire disparaitre, elles sont très limitées. Diverses optimisations permettent d'émettre des accès mémoire dans le désordre, exactement comme l’exécution dans le désordre le fait pour les autres instructions. Elles utilisent pour cela des ''schedulers'' spécialisées dans les accès mémoire. Il faut vraiment insister sur le fait que l'exécution dans le désordre proprement dite ne se préoccupe pas des micro-opérations mémoire. Pour faire la distinction, on parle de '''désambiguïsation mémoire''' (''memory disambiguation'') pour parler de l'exécution dans le désordre des accès mémoire. Le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. La raison est que la donnée lue est utilisée par d'autres instructions, dépendantes de la lecture. Elles sont donc à la tête d'une chaine de dépendances de données qui peut bloquer le pipeline si elle n'est pas résolue très tôt. Dès qu'une lecture peut être lancée, elle accède directement au cache de données. Par contre, si l'''aliasing'' ne le permet pas (écriture à la même adresse pas encore effectuée) ou que l'adresse à lire n'a pas encore été calculée, la lecture attend son tour. Le problème est que la désambiguïsation mémoire demande de comparer des adresses, pour détecter les dépendances d'adresse. Et cela pose de sérieuses contraintes pour son implémentation. L'exécution dans le désordre normale compare des registres, ce qui est beaucoup plus simple. Les registres utilisés sont directement encodés dans l'instruction elle-même, ce qui fait qu'on peut détecter les dépendances de registre après le décodage. Et elles peuvent même être éliminées par le renommage de registre pour les dépendances WAW et WAR. Par contre, les adresses ne sont pas encodées dans l'instruction elle-même (sauf avec le cas particulier de l'adressage absolu), mais sont lues depuis les registres ou calculées par une unité de calcul. Les adresses ne sont donc connues qu'une fois que l'instruction a attendu suffisamment de temps dans le ''scheduler''. ==L’émission dans l'ordre des accès mémoire== Avant toute chose, faisons quelques rappels sur l'exécution dans le désordre "normale", sans désambiguïsation mémoire. Les micro-opérations renommées sont mises en attente dans un ''scheduler'', le temps que leurs opérandes soient disponibles. Les µops mémoire font cependant exception. ===La file de µops mémoire=== Avec l''''émission dans l'ordre des accès mémoire''', le processeur émet les accès mémoire dans l'ordre du programme. Cela implique que l'unité mémoire n'a pas de ''scheduler'', ni de station de réservation associée. À la place, les micro-opérations mémoire sont placées dans une file de micro-opération dédiée, appelée la '''file de µops mémoire'''. La file de µops mémoire est une mémoire FIFO, dont chaque entrée peut accueillir une µops mémoire. [[File:Unité de gestion des accès mémoires dans l’ordre.png|centre|vignette|upright=2.0|Unité de gestion des accès mémoires dans l’ordre]] Par contre, le processeur utilise des ''schedulers'' pour les unités de calcul. Autant les accès mémoire se font dans l'ordre, autant les instructions arithmétiques/logiques/branchements/autres sont exécutées dans le désordre. En clair, le processeur supporte l'exécution dans le désordre, sauf pour les micro-opérations mémoire. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] Vu de l'extérieur, il faut attendre qu'un accès mémoire soit terminé pour lancer le suivant. Avant d'émettre une micro-opération mémoire, la file de micro-opération (son ''scoreboard'') vérifie si l'unité d'accès mémoire est libre ou non. Si elle est occupée, un accès mémoire est en cours, et on ne peut pas émettre de micro-opération mémoire. Une fois l'accès mémoire terminé, la mémoire ou le cache envoient un signal pour indiquer que l'accès est fini, et c'est au tour d'une nouvelle micro-opération mémoire. L'unité mémoire a un port sur lequel on envoie la micro-opération émise, que ce soit une écriture ou une lecture. Une file de µops implique la ''lecture après émission'', à savoir que les µops mémoire quittent la file de µops, avant de lire les registres adéquats. Il y a donc une connexion entre banc de registre et unité mémoire, comme sur n'importe quel autre processeur. Mais la file de µops n'est pas connectée au banc de registre, ni au réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] L'organisation précédente laisse penser que les calculs d'adresse sont forcément réalisés dans l'unité mémoire. Mais ce n'est pas une obligation. Il est parfaitement possible de scinder une µops mémoire en deux : une µop mémoire proprement dite et une µop de calcul d'adresse. La liaison entre les deux doit cependant se faire en utilisant un registre. L'adresse calculée est enregistrée dans un registre d'adresse "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. ===La file d'écriture : une FIFO de mise en attente des écritures=== L'unité mémoire exécute une µops mémoire à la fois. Avec un détail particulier : les écritures doivent attendre que toutes les instructions se terminent, avant de pouvoir s'exécuter. Sans ça, les exceptions précises sont impossibles, la prédiction de branchement l'est aussi et l'exécution dans le désordre aurait des conséquences fâcheuses (en exécutant des écritures en avance). Précisément, les écritures doivent respecter deux contraintes : s'exécuter dans le tout dernier étage du pipeline, s'exécuter dans l'ordre du programme. Il serait possible de bloquer l'émission des écritures tant que le ROB ne donne pas son accord. Mais ce n'est pas du tout ce qui est fait. A la place, les écritures sont mises en attente dans une mémoire FIFO, appelée la '''file d'écriture''', ou encore la ''Write Queue''. Les écritures sont mises en attente, jusqu'à ce que le ROB leur donne le okay pour transfer l'écriture en mémoire RAM. Nous en avions déjà parlé dans le chapitre sur le pipeline, dans la section sur les exceptions précises. Mais quelques rappels ne feront pas de mal. L'écriture est insérée dans la file d'écriture lorsqu'elle est émise, elle en sort à l'étage de ''Writeback'' si tout se passe bien. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente, faites à tort. De plus, la file d'écriture est une mémoire FIFO, qui mémorise les écritures dans l'ordre du programme. En effet, les écritures sont ajoutées dans la file d'écriture lorsqu'elles sont émises, et elles sont émises dans l'ordre du programme. [[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Write Queue sur un pipeline fixe]] La file d'écriture garantit que les écritures sont dans l'ordre du programme. Et cette propriété élimine totalement les dépendances WAW. Le fait que les écritures se font à la fin du pipeline élimine quant à elle les dépendances WAR. Il ne reste plus qu'à gérer les dépendances RAW, celles où une lecture lit une donnée écrite par une autre instruction. Une telle situation survient, car les écritures sont mises en attente pendant un certain temps, la donnée écrite peut être lue pendant ce laps de temps. La lecture ne peut pas lire la donnée dans le cache, car la donnée à lire n'y est pas encore enregistrée. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire, mais il existe. Il se manifeste surtout sur les processeurs avec peu de registre, où des données doivent régulièrement être déplacées temporairement en mémoire RAM. Pour éviter cela, une solution consiste mettre en attente les lectures si une écriture est en attente. Les lectures sont maintenues dans la file de µops tant que la file d'écriture n'est pas vide. La file de µops mémoire est suivie d'un circuit de détection des dépendances, un '''''scoreboard'' mémoire''' qui est assez simple. L'unité mémoire fournit un bit qui indique si la file d'écriture est vide ou non, ainsi qu'un second bit qui indique qu'elle est vide. Les deux ne sont pas compliqués à générer, ils font partie intégrante de l'interface de toute mémoire FIFO digne de ce nom. Le ''scoreboard'' mémoire utilise ces deux bits pour savoir s'il peut émettre une micro-opération mémoire. Il n'émet pas de lecture tant que la file d'écriture n'est pas vide. Il peut par contre émettre des écritures tant qu'elle n'est pas pleine. Une meilleure solution serait d'interdire les lectures, mais seulement en cas de dépendance mémoire détectée. Et c'est là qu'intervient la technique du '''''load bypassing'''''. Elle autorise une lecture, si elle n'a aucune dépendance avec les écritures mises en attente dans la file d'écriture. Elle doit être mise en attente dans le cas contraire. Pour l'implémenter, il faut comparer l'adresse à lire avec toutes les adresses dans la file d'écriture. La lecture est exécutée immédiatement si aucune correspondance n'est trouvée, mais elle est mise en attente dans le cas contraire. Pour cela, la file d'écriture est modifiée et devient un mélange entre FIFO et mémoire associative. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'' : la lecture doit être bloquée. Sinon, c'est un défaut de ''Write Queue'', la lecture accède au cache, là où se trouve la donnée à lire. Dans le chapitre sur le contournement, nous avons parlé d'une forme de contournement qui corrige ce problème. Les lectures peuvent lire la donnée depuis la file d'écriture si besoin, ce qui renvoie la donnée adéquate. Pour cela, les lectures consultent à la fois le cache et la file d'écriture, comme dit plus haut. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'', ou encore de '''réacheminement écriture-vers-lecture'''. [[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]] La lecture est mise en attente dans l'unité mémoire, vu que c'est elle qui fait les comparaisons d'adresse. Du point de vue extérieur, le ''scoreboard'' mémoire ne voit pas s’il y a eu succès dans la file d'écriture ou non. Il voit juste que l'unité mémoire est occupée et ne peut pas accepter de nouvelle lecture. Elle reste occupée durant quelques cycles, en cas de défaut de ''Write Queue'', le temps de faire la lecture dans le cache. Par contre, en cas de succès de ''Write Queue'', elle reste occupée tant que la lecture n'a pas donné de résultat, ce qui inclue le temps de mise en attente. Les processeurs haute performance implémentent tous le ''Store to load Forwarding'', mais quelques processeurs basse performance ne le font pas. Ils se contentent du ''load bypassing'', qui offre des performances correctes pour un cout en matériel bien plus modeste. Pour donner un exemple, la micro-architecture Jaguar d'AMD était dans ce cas. Elle a été utilisée sur les consoles de jeu de 8ème génération, comme la Xbox One et la Playstation 4. Les processeurs haute performance préférent utiliser plus de circuits pour implémenter le ''Store to load Forwarding'', mais l'implémentation n'est souvent pas complète. En effet, l'implémentation est souvent simple et ne prend en compte que des lectures/écritures à des adresses identiques, sans compter que la taille des données doit aussi être identique. Une écriture d'un entier de 32 bit sera contournée et envoyée à une lecture de 32 bits à la même adresse. Mais la lecture lit seulement 16 bits sur les 32 écrits, le contournement peut ne pas marcher. Concrètement, ce cas précis fonctionnera. Mais le cas inverse, avec une lecture lisant plusieurs écritures de taille plus petite, ne marchera pas sur tous les processeurs. Ne parlons pas du cas où les données écrites sont à cheval sur deux lignes de cache. ==L'exécution anticipée des calculs d'adresse== L'exécution dans le désordre fonctionne mieux si les lectures sont exécutées le plus tôt possible. Les lectures lisent des données qui sont utilisées par d'autres instructions. Si la lecture tarde, les autres instructions sont mises en attente. Exécuter les lectures le plus tôt possible est donc crucial. Mais comment faire ? Un moyen pour ça est de faire en sorte que les adresses à lire/écrire soient connues le plus tôt possible. Mais cela implique que les calculs d'adresse ne sont pas fait en même temps que l'accès mémoire. Pour cela, il est possible de séparer les µops mémoire en deux micro-instructions : une pour le calcul d'adresse et l'autre pour les accès mémoire. On parle de '''calcul anticipé des adresses'''. ===La ''Load/Store Queue'' : une file de µops améliorée=== Implémenter cette technique demande de faire deux choses. Premièrement, il faut faire les calculs d'adresse en-dehors de l'unité mémoire, soit dans une ALU entière, soit dans une unité de calcul d'adresse dédiée. Deuxièmement, les adresses calculées doivent être mises en attente quelque part, et être liées facilement à la µop mémoire associée. Pour le second point, la file de µops mémoire parait être un bon candidat, mais n'est pas exactement la solution. Une file de µops au sens strict ne mémorise pas les opérandes des µops, donc pas d'adresse. Cela implique la lecture après émission, ce qui veut dire que l'adresse à lire/écrire est lue depuis les registres une fois que la µops sort de la file de µops (idem pour la donnée à écrire). En pratique, cette implémentation rend difficile de faire les calculs d'adresse ailleurs que dans l'unité mémoire. Heureusement, il est possible d'adapter la file de µops pour gérer la lecture avant émission. L'idée est qu'elle mémorise, pour chaque µop mémoire, l'adresse à lire/écrire et d'autres informations. Il ne s'agit donc plus vraiment d'une file de µops mémoire mais quelque chose d'autre, une sorte d'intermédiaire entre une station de réservation et une file de µops. D'un côté, elle fonctionne comme une file de µops car elle émet les µops dans l'ordre du programme. De l'autre, elle mémorise les opérandes des µops mémoire, ce qui en fait une pseudo-station de réservation. Une telle structure est appelée une '''''Load/Store Queue''''' (LSQ). Une LSQ contient plusieurs entrées, chaque entrée pouvant accueillir une µops mémoire et ses opérandes. Une entrée contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures. Les deux derniers champs sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. Elle contient aussi des bits de validité, qui indique si l'adresse ou la donnée sont disponibles, ou si le champ associé est vide. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2|Load Store Queue unifiée]] La LSQ doit faire le lien entre une adresse calculée par l'ALU entière, et l'entrée associée de la LSQ. Pour cela, l'unité d'émission peut associer un '''identifiant LSQ''' à une µops mémoire. L'identifiant est généré à l'émission. Aussi bien la µops de calcul d'adresse que a µop mémoire ont toutes deux le même identifiant. L'identifiant est propagé avec la µop de calcul d'adresse, comme tout signal de commande. En sortant de l'AGU, l’identifiant est transmis à la LSQ en même temps que l'adresse. La LSQ regarde quelle entrée a cet identifiant, et mémorise l'adresse dedans. La gestion des données à écrire se fait elle avec le système de contournement normal. La LSQ sait dans quel registre se trouve la donnée à écrire, elle surveille juste le système de contournement pour détecter quand la donnée est disponible. ===Le contournement des adresses mémoire avec une LSQ=== Avec une LSQ, les registres sont lus quand la µop mémoire est insérée dans la LSQ, si elles sont disponibles. Si elles ne sont pas encore disponibles, elles seront envoyées par le système de contournement plus tard. Cela implique qu'il y ait un système de contournement, qui relie les ALUs entières à la LSQ. Les AGUs envoient leur résultat à la LSQ, qui place alors l'adresse calculée dans la bonne entrée, la bonne "µop". [[File:Load-Store Queue.png|centre|vignette|upright=2|Load-Store Queue]] Une autre solution utilise des ALU spécialisées dans les calculs d'adresse, appelées des ''Adress Generation Unit'', abrévié AGU. L'avantage est que cela simplifie grandement le réseau de contournement : seules les AGU sont connectées à la LSQ. Un autre avantage est lié à l'adressage de la destination. En utilisant des opérations entières usuelles, il faudrait préciser que le résultat est à destination de la LSQ, pour configurer le réseau de contournement. Avec des AGU, les calculs d'adresse sont des micro-opérations différentes des additions et soustraction, elles sont envoyées au AGU spécifiquement, pas besoin de préciser la destination. [[File:ALU et LSQ.png|centre|vignette|upright=2|AGUs et Load-Store Queue.]] Dans les deux cas, les µops mémoire sont alors considérées comme des opérations entières un peu spéciales, gérées par le système d'exécution dans le désordre. Elles peuvent être émises dans le désordre, mais la LSQ se chargera de faire les accès mémoire dans l'ordre. Il y a donc calcul des adresses dans le désordre, mais accès mémoire dans l'ordre. ==Les ''schedulers'' mémoire unifiés== Avec la technique précédente, les µops mémoire s'exécutent dans l'ordre, à savoir que l'on ne peut pas déplacer une lecture avant ou après une autre. Il arrive que des lectures prêtes soient mises en attente, parce qu'une lecture précédente attend ses opérandes. Et ce retard sur les lectures se répercute sur les instructions lecture-dépendantes, donc sur le reste du pipeline.Les conséquences sur les performances sont assez importantes. Mais avec l'implémentation qui va suivre, il est possible exécuter les lectures dans le désordre tant que les conditions d'''aliasing'' sont respectées. L'implémentation remplace les files de µops mémoire par un ''scheduler'' spécialisé dans les µops mémoire. Il sera appelé le '''''scheduler'' mémoire''' dans ce qui suit. [[File:Processeur avec émission dans le désordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans le désordre des accès mémoire]] Une entrée du ''scheduler'' mémoire contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures (inutile pour les écritures). Elle contient aussi des bits de validité pour chaque champ qui indiquent si l'adresse d'accès est connue, ou si la donnée à écrire est disponible pour une écriture. Les champs pour la donnée à écrire ou le registre de destination sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2.5|''Load/Store Queue''.]] Lorsqu'une micro-opération mémoire est émise, elle est insérée dans le ''scheduler'' mémoire. Si l'adresse à lire/écrire est disponible, elle est immédiatement lue depuis les registres, idem pour une éventuelle donnée à écrire. Sinon, la micro-opération attend dans le ''scheduler'' mémoire que son adresse soit calculée par le processeur. ===Les calculs d'adresse avec un ''scheduler'' mémoire=== Le ''scheduler'' mémoire détecte des dépendances d'adresse entre instructions. Pour cela, il compare toutes les adresses à lire/écrire. Il mémorise donc les dites adresses, ce qui en fait une station de réservation. Et cela implique que ces adresses ont été calculées ailleurs, dans une ALU entière, ou dans une AGU séparée de l'unité mémoire. L'implémentation la plus simple effectue les calculs d'adresse dans les ALU entières. La sortie de toutes les ALU entière est reliée au ''scheduler'' mémoire. [[File:Contournement pour la file de µops mémoire.png|centre|vignette|upright=2|''scheduler'' mémoire]] Une autre implémentation utilise des unités de calcul d'adresse dédiées, afin de simplifier le réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse via AGU.png|centre|vignette|upright=2|ALU et ''scheduler'' mémoire]] Une conséquence est que l'implémentation impose que toutes les instructions mémoire sont découpées en deux µops : une µop pour calculer l'adresse, et une µop mémoire. C'est un défaut assez important, car une instruction mémoire prendra deux places dans le ROB, dans les stations de réservation, etc. Vu que les instructions mémoire sont très fréquentes, la capacité du ROB/autres est alors fortement réduite. Il faut noter que les adresses calculées dans l'ALU/AGU doivent atterrir dans l'entrée adéquate, celle de la µops qui doit recevoir l'adresse. La seule solution est que le ''program counter'' de la µops est transmis dans le pipeline, propagé avec la µops de calcul d'adresse. Quand le résultat est calculé, le ''program counter'' associé est envoyé avec au ''scheduler'' mémoire. Le ''scheduler'' mémoire enregistre alors l'adresse dans l'entrée qui est taguée avec cette adresse. ===La détection des dépendances mémoire=== Le ''scheduler'' mémoire intègre un système de détection des dépendances, qui est bien différent de celui d'une station de réservation normale. Les ''schedulers'' mémoire comparent non pas des noms de registres, mais des adresses/opérandes. Pour détecter les dépendances, le ''scheduler'' mémoire compare les adresses à lire/écrire. Pour les µops dont l'adresse est inconnue, le cas dépend de l'implémentation. La version la plus conservatrice suppose qu'elle a des dépendances possibles avec toutes les autres. Chaque micro-opération en attente compare son adresse avec celle de toutes les micro-opérations précédentes. Ou presque, la comparaison ne doit tenir compte que des écritures, et de celles situées avant dans l'ordre du programme. Le ''scheduler'' mémoire doit donc mémoriser les instructions dans l'ordre du programme, tout en faisant des comparaisons d'adresse. Ça en fait une sorte hybride entre mémoire associative et mémoire FIFO. Nous avions vu dans le chapitre "L'exécution dans le désordre" qu'il y a plusieurs manières pour implémenter une station de réservation : avec une mémoire associative, avec préplanification, avec des matrices de dépendances. Le problème est qu'on ne peut pas utiliser une mémoire associative pour le ''scheduler'' mémoire, qui utilise des comparateurs pour comparer les noms de registres/adresses. En effet, la détection des dépendances mémoire demande de faire beaucoup de comparaisons : chaque adresse est comparé à toutes les autres. Et les adresses faisant facilement 32 à 64 bits, les comparateurs sont bien plus imposants que ceux qui comparent des noms de registres de 5-10 bits. Le cout en matériel serait trop élevé, de même que le temps de calcul des dépendances. À la place, on utilise des matrices de dépendances. La détection des dépendances mémoire utilise précisément deux matrices de ce type : une matrice de disponibilité, et une matrice de dépendance. Ces matrices forment une espèce de tableau carré, organisé en lignes et en colonnes. L'instruction dans la énième entrée se voit attribuer la énième ligne et la énième colonne. À l'intersection d'une ligne et d'une colonne, on trouve un bit qui indique si l'instruction de la ligne et celle de la colonne ont une dépendance. La '''matrice de disponibilité''' permet de bloquer l'exécution des lectures/écritures tant qu'une instruction précédente n'a pas calculé son adresse, tant qu'on ne peut pas vérifier les dépendances. Lorsqu'une instruction est ajoutée dans le ''scheduler'' mémoire, elle met tous les bits de la colonne attribuée à 1 si l'adresse à lire/écrire est inconnue, puis les met à 0 une fois l'adresse calculée. Une instruction ne peut pas démarrer si, sur la ligne qui lui est attribuée, se trouve un 1. Plus précisément, si on trouve un 1 pour les instructions plus anciennes, ce qui fait qu'une partie de la matrice est ignorée. Les bits notés X dans le tableau suivant sont ignorés, car ils précisent la disponibilité des opérandes d'un opérande ultérieure, ce qui est inutile. En clair, les calculs d'adresse modifient les colonnes, et on vérifie les lignes pour autoriser l'exécution. {|class="wikitable" |- ! Instructions mémoire dans la LSB, triées de la plus ancienne à la plus jeune ! 0 !! 1 !! 2 !! 3 !! 4 !! 5 !! 6 |- ! 0 | 1 || X || X || X || X || X || X |- ! 1 | 0 || 0 || X || X || X || X || X |- ! 2 | 0 || 1 || 0 || X || X || X || X |- ! 3 | 0 || 1 || 1 || 1 || X || X || X |- ! 4 | 0 || 0 || 0 || 0 || 1 || X || X |- ! 5 | 0 || 0 || 1 || 0 || 0 || 1 || X |- ! 6 | 1 || 1 || 1 || 1 || 1 || 1 || 0 |} Les '''matrices de dépendances''' fonctionnent sur le même principe, mais déterminent les dépendances mémoire. Lorsque le processeur a calculé une adresse, il la compare avec les adresses à lire/écrire déjà dans le ''scheduler'' mémoire. Il met alors à jour les bits de la colonne de la matrice de dépendance : le bit à l'intersection d'une ligne et d'une colonne est mis à 0 si les deux instructions n'ont pas de dépendances, à 1 si elles en ont une. Une instruction mémoire est exécutée si tous les bits de sa ligne sont à 0. Là encore, on ne tient compte que des bits pour les instructions plus anciennes, la matrice a encore une fois une forme triangulaire, comme pour la matrice de disponibilité. Une optimisation scinde le ''scheduler'' mémoire en deux structures, aux rôles légèrement différents. La première gère la disponibilité des opérandes, la seconde gère seulement les dépendances mémoire. La première est une station de réservation où les instructions sont mises en attente tant que leurs opérandes ne sont pas prêts. La seconde met en attente des micro-opérations dont les opérandes sont prêts, mais qui doivent attendre pour une autre raison : dépendance mémoire, cache occupé, etc. L'avantage est qu'on remplace une grosse station de réservation par deux plus petites, mais l'intérêt est surtout relié au système de détection des dépendances. Sans cette optimisation, on a deux matrices énormes reliées à une ''Load/Store Queue'' unique. Avec la séparation, la matrice de disponibilité est reliée uniquement à la première station de réservation, alors que la matrice de dépendance est reliée uniquement à l'autre. Les deux matrices sont donc plus petites, car reliées à deux stations de réservation mémoire plus petites. ==L'exécution spéculative des accès mémoires== Pour rappel, le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. Les données lues sont en effet, le départ d'une série d'instructions dépendantes, généralement des instructions arithmétiques, mais parfois d'autres lectures. Quand on manipule des structures de données, il n'est pas rare qu'une lecture lise un pointeur-adresse utilisé par une autre lecture. Les techniques de désambiguïsation mémoire que nous avons vu précédemment sont dites conservatrices, à savoir qu'elles ne changent pas l'ordre de deux instructions mémoire si elles ont une dépendance d'adresse. Elles exécutent une lecture le plus tôt possible, mais seulement si on sait que celle-ci n'aliase pas d'écritures précédentes. Et cela demande de connaitre les adresses des écritures précédentes dans l'ordre du programme. Si une lecture est précédée par une ou plusieurs écritures, les adresses à écrire doivent être connues. Il se peut en effet qu’une écriture dont on ne connait pas l'adresse aliase la lecture. La garantie est liée à la présence de la file de µops mémoire ou la file de µops STORE. Si une seule écriture a une adresse inconnue, elle reste dans la file de µops mémoire et bloque l'émission de toutes les autres micro-opérations mémoire, lecture comme écriture. Mais il existe des techniques dites spéculatives, qui modifient l'ordre des accès mémoire sans se soucier de leurs dépendances. Elles émettent les lectures dès que leurs opérandes sont prêts, sans se préoccuper de l'aliasing avec une écriture précédente. Pour éviter tout problème, le processeur vérifie si aucune dépendance mémoire n'est violée et corrige le tir dès qu'une violation est détectée. Le processeur vérifie s'il a fait une erreur de prédiction, en vérifiant les adresses à écrire ou lire. En cas d'erreur, le processeur fait comme lors d'une prédiction de branchement ratée : il remet le pipeline en ordre et ré-exécute les accès mémoire dans le bon ordre. Il s'agit d'un cas particulier d’'''exécution spéculative''', au même titre que la prédiction de branchement. Toutes les techniques de ce genre exécutent des lectures dans le désordre. L'avantage est que cela permet d'exécuter les lectures en avance. Vu que la donnée lue est l'opérande d'autres instructions, les exécuter en avance fait prendre de l'avance à un paquet d'instructions. Mais il faut comparer cet avantage au risque de mauvaise prédiction. Et aussi bizarre que cela puisse paraître, cela donne de bons résultats. Il faut dire que les situations où une lecture relit une donnée tout juste écrite sont rares : même pas 2 à 3 % des lectures. Les dépendances mémoire sont donc très rares, les cas d'''aliasing'' sont vraiment limités. En termes de cout en transistors, le hardware qui vérifie les dépendances mémoire reste globalement le même à peu de choses près, il est juste déplacé dans le pipeline et adapté à sa nouvelle fonction. ===La file de lectures=== La technique que nous allons voir émet les lectures dans l'ordre, ce qui simplifie énormément la gestion des dépendances. Les lectures sont émises dans l'ordre, les écritures aussi. Par contre, l'ordre entre lectures et écritures n'est pas respecté. Une lecture peut passer avant une écriture sans problèmes, voire avant plusieurs écritures d'un seul coup. Une telle lecture exécutée en avance d'une autre écriture est appelée une '''lecture spéculative'''. Une lecture spéculative peut donc sauter devant une ou plusieurs écritures, qui seront appelées des '''écritures sautées'''. Une écriture sautée est une précédant une lecture spéculative, dans l'ordre du programme. Évidemment, il se peut qu'une écriture sautée écrive la donnée qui est lue par la lecture spéculative. Il y a alors une violation de dépendance mémoire. Mais le processeur suppose qu'il n'y a pas de dépendantes de ce type et émet la lecture en avance. Rassurez-vous, il surveille quand même la situation, pour tout annuler ensuite s'il détecte une violation de dépendance mémoire. J'ai dit plus haut que le processeur émet les lectures dans l'ordre, ainsi que les écritures dans l'ordre. Pour cela, la LSQ est scindée en deux files de µops : une pour les µops LOAD et une autre pour les µops STORE. Les cours d'architecture des ordinateurs nomment souvent ces deux files en utilisant les termes de ''file de STORE'' (''store queue'') et de ''file de LOAD'' (''load queue''), mais il y a un risque de confusion avec la file d'écriture vue précédemment, et la file de lecture qu'on introduira dans la suite. Dans ce qui suit, nous parlerons de '''file de µops LOAD''' et de '''file de µops STORE''', pour éviter toute confusion. Les deux files sont chacune connectées à l'unité mémoire, via un port dédié. L'unité mémoire a donc un port pour les lectures, et un port pour les écritures. Le port d'écriture est relié à la file d'écriture, le port de lecture est directement relié au cache de données. Il faut noter qu'une unité mémoire à deux ports n'est pas si bizarre que cela. En effet, le cache de données a lui aussi deux ports, dans la majorité des cas, avec un port de lecture et un port d'écriture. [[File:Load Ordering, Store Ordering.jpg|centre|vignette|upright=2|Exécution dans le désordre des lectures.]] Maintenant, voyons comment sont détectées les violations de dépendances mémoire. Les dépendances qui peuvent être violées dans ce cas de figure se font entre une écriture sautée et une lecture spéculative. Si une lecture spéculative lit une donnée qui est écrite par une écriture sautée, alors une dépendance RAW a été violée. Pour les détecter, le processeur garde une trace des lectures spéculatives; tant que les instructions précédentes ne sont pas terminées. Pour cela, il utilise une file d'attente, qui mémorise les lecture spéculatives. Nous l'appellerons la ''file de vérification des lectures'', ou encore la '''file de lectures'''. Elle est une sorte d'équivalent de la file d'écriture, mais pour les lectures. Les lectures sortent de la file µops LOAD dès que leurs adresses sont calculées, sans tenir compte des dépendances mémoires. Elles sont alors ajoutées à la file de lectures et s'exécutent immédiatement, de manière spéculative. Pour vérifier qu'il n'y a pas d'erreur de spéculation, le processeur conserve l'adresse de la lecture effectuée dans la file de lectures. Les lectures quittent la file de lecture quand toutes les instructions précédentes dans l'ordre du programme sont terminées, à savoir tant quand elles ont quitté le tampon de réordonnancement/ROB. Notons que la file de vérification des lectures contient les lectures dans l'ordre du programme. En effet, les lectures sont accumulées en sortant de la file de µops LOAD, où elles sont triées dans l'ordre du programme. Et cela permet de gérer les erreurs de prédiction parfaitement, en annulant les instructions suivant une erreur de prédiction. [[File:Spéculation avec une file de lecture.jpg|centre|vignette|upright=2.5|Spéculation avec une file de lecture.]] Reste à détecter les erreurs de prédiction, ce qui peut être fait avec deux méthodes. La première détecte les violations de dépendances mémoire à chaque écriture. L'idée est que chaque écriture vérifie si une lecture anticipée n'a pas lue la donnée écrite en avance. Pour cela, quand une écriture est émise, elle consulte la file de lecture pour voir s'il y a un conflit d'adresse. La vérification fait cependant gaffe à ne prendre en compte que les lectures situées après l'écriture dans l'ordre du programme. Précisément, elle envoie l'adresse à écrire à la file de lecture et cherche si une lecture ultérieure a eu lieu à la même adresse. Si c'est le cas, cela signifie qu'une lecture anticipée aurait du lire la donnée actuellement écrite, mais que la lecture a été exécutée trop en avance. La lecture n'a pas lu la bonne donnée, il y a eu une violation de dépendance mémoire. Pour faire cette vérification, la file de lecture est un mélange entre FIFO et cache/mémoire associative, sur le même modèle que la file d'écriture. La différence est que la consultation de la file de lecture ne sert pas à faire du contournement, mais à détecter les dépendances. Quand une dépendance est détectée, l'écriture se fait normalement. Par contre, la lecture marquée comme invalide dans la file de lecture. L'exécution du programme se poursuit, jusqu'à ce que la lecture arrive à l'étape de ''commit'', à savoir que le ROB lui dit que les instructions précédentes sont terminées sans encombre. Là, la lecture étant invalide, le processeur déclenche une procédure de réparation, qui vide partiellement le pipeline de toutes les instructions qui suivent la lecture. La lecture est de plus ré-executée, afin de lire la bonne donnée. Avec la seconde méthode, les violations de dépendance mémoire sont détectées au moment où la lecture quitte à la file de lectures et le ROB. L'idée est de comparer la donnée lue de manière spéculative avec la donnée lue de manière non-spéculative. Pour faire la comparaison, la lecture spéculative copie la donnée lue dans la file de lecture. Quand la lecture quitte la file de lecture et le ROB, la lecture est ré-exécutée et la donnée relue. Le processeur compare alors la donnée lue à ce moment avec la donnée mémorisée dans la file de lecture. Si la donnée est différente, alors il y a eu erreur de violation de dépendance mémoire. Évidemment, relire une donnée n'est pas gratuit. Il est possible de limiter son impact en performance en dédiant un port de lecture sur le cache rien que pour ça. Mais le cout en circuit est comparable à l'usage d'une FIFO associative de la technique précédente. Mais quoi qu'il en soit, le cache doit permettre des lectures en un seul cycle, sans quoi la méthode ne marche pas vraiment. ===La prédiction de dépendances mémoires=== Certains processeurs essayent de prédire si deux accès mémoires sont dépendants : ils incorporent une unité qui va fonctionner comme une unité de prédiction de branchement, à la différence qu'elle prédit les dépendances mémoires. Si cette unité prédit que deux accès mémoires sont indépendants, le processeur les exécute dans le désordre, et les exécute dans l'ordre du programme dans le cas contraire. Il faut prendre en compte les erreurs de prédiction, ce qui est fait comme pour les mauvaises prédictions de branchement : on vide le pipeline. Beaucoup de techniques de prédiction des dépendances mémoire ont été inventées et en faire une revue exhaustive serait long et fastidieux. On pourrait citer les ensembles colorés (''color sets''), et bien d'autres techniques. Mais nous n'allons parler que des techniques les plus élémentaires. Quoi qu’il en soit, la recherche sur le sujet est assez riche, comme toujours quand il s'agit d'architecture des ordinateurs. On peut améliorer cette technique en mémorisant les instructions qui ont causé une mauvaise prédiction de dépendance mémoire. La prochaine fois qu'on exécute ces instructions, on sait qu'il y a de grandes chances qu'il se produise une erreur de prédiction, ce qui pousse à ne pas les exécuter de manière spéculative. On peut pour cela réutiliser les techniques de prédiction de branchement, tels des compteurs à saturations, mis à jour en cas d'erreurs de prédiction de dépendances mémoire. Dans tous les cas, on trouve un cache, équivalent au ''branch target buffer'', qui mémorise les instructions fautives (leur ''Program Counter''), avec d'autres informations comme les adresses des instructions dépendantes. La première classe de techniques du genre consiste à mémoriser les lectures qui ont causé une violation de dépendance, tandis que l'autre ne mémorise que les écritures. Cette dernière est l'approche utilisée par le '''cache de barrières d’écriture''' (store barrier cache). Enfin, nous allons conclure avec une dernière technique : celle des '''ensembles d’écritures''' (store sets). Cette technique est capable de gérer le cas où une lecture dépend de plusieurs écritures : le processeur mémorise l'ensemble des écritures avec lesquelles la lecture a déjà eu une dépendance. Cet ensemble d'écritures associées à une lecture est appelé tout simplement un ensemble d’écritures, et reçoit un identifiant (un nombre) par le processeur. Cette technique demande d'utiliser deux tables : * une qui assigne un identifiant d’ensemble d’écritures à l'instruction en cours ; * une autre qui mémorise les ensembles d’écritures sous la forme d'une liste chainée : le début de la liste correspond à l'écriture la plus ancienne de l’ensemble. Cela permet d'obtenir l'écriture la plus ancienne dans cet ensemble d’écritures directement. Quand le processeur détecte une violation de dépendance entre une lecture et une écriture, elle ajoute l'écriture dans l’ensemble d’écritures adéquat. Le déroulement d'une lecture demande d'accéder à la première table pour récupérer l'identifiant de l’ensemble d’écritures, et l'utiliser pour adresser la seconde table. S'il y a dépendance, cet accès renvoie l'écriture fautive en cas de dépendance. Quand une écriture est envoyée à l'unité mémoire, celle-ci va accéder à la table de correspondances de la même manière qu'une lecture, et va récupérer l'identifiant de l’ensemble d’écritures auquel elle appartient, identifiant qui est utilisé pour vérifier s'il y a une écriture en cours d’exécution. Si c'est le cas, cela signifie que l'écriture en cours d’exécution doit s’exécuter avant l'écriture qui a consulté la table : cette dernière est mise en attente. ==La prédiction d'adresse et de valeur== D'autres techniques de prédiction mémoire plus élaborées que les précédentes existent. Il s'agit de la prédiction d'adresse et de la prédiction de valeur. Leur nom trahit ce qu'elles font. La première technique tente de prédire l'adresse d'une lecture/écriture, alors que la seconde tente carrément de prédire quelle sera la donnée lue. Il y a bien de la recherche académique sur le sujet, mais il s'agit de techniques assez osées, dont on voit mal comment elles pourraient fonctionner. L'efficacité de cette technique a été étudiée grâce à des simulateurs. Suivant les études ou les programmes, on trouve des résultats qui varient pas mal. Dans certains cas, la performance baisse un peu, et dans d'autres, on peut avoir des gains de plus de 10% ! Mais dans des cas normaux, on trouve des gains de 4-5% environ. Ce qui est tout de même pas mal à l'heure actuelle. Cependant, en Janvier 2025, à l'heure où j'écris ces lignes, il a été découvert la première utilisation de ces deux techniques dans un processeur commercial. La découverte fait suite à la publication de deux vulnérabilités matérielles sur les CPU Apple M2 et M4 et quelques CPU ARM, qui font justement usage de ces deux techniques. Les deux vulnérabilités, appelées SLAP et FLOP, sont assez similaires aux attaques Spectre et Meltdown, sauf qu'elles attaquent non pas la prédiction de branchement, mais la prédiction d'adresse et de valeur du CPU. ===La prédiction d'adresse=== La prédiction d'adresse tente de prédire l'adresse d'une lecture à l'avance, ce qui permet de lancer les lectures en avance. Si la prédiction est correcte, on gagne quelques cycles qui permet aux unités de calcul de faire leurs calculs plus en avance, sans compter que les mécanismes de désambiguïsation mémoire fonctionnent plus efficacement. Si jamais cette adresse est connue plus tôt, on détecte les dépendances plus rapidement et on peut agir en conséquence. Des variantes de ces unités de prédiction d'adresse sont utilisées pour précharger des données depuis le cache de donnée. Et leur efficacité est assez bonne, voire excellente dans certains cas. La différence est que la prédiction d'adresse est utilisée pour prédire les adresses à lire depuis le cache vers les registres, ce qui est le sujet de cette sous-partie. La prédiction d'adresse est réalisée par une sorte de cache, qui associe à chaque instruction la prochaine adresse à lire. Nous appellerons ce cache la '''table de prédiction d'adresse'''. Pour faire l'association instruction <-> adresse lue/écrite, le tag du cache utilise l'adresse de l'instruction (le ''program Counter''), alors que la ligne de cache associée contient l'adresse prédite. Son contenu est utilisé pour faire une prédiction. Lorsqu'une lecture est émise, la table est consultée pour récupérer en avance l'adresse à lire, avant que celle-ci soit calculée par l'ALU. La lecture est alors exécutée en avance, avant que la vraie adresse soit connue, en utilisant l'adresse prédite. Reste qu'il faut des circuits autour de cette table de prédiction d'adresse, pour déterminer si la prédiction est correcte, ainsi que pour remplir cette table avec une adresse prédite. Pour cela, il y a un circuit de prédiction d'adresse dédié. Il récupère les instructions qui quittent le ROB ou la file des lectures mémoire, afin de vérifier les lectures qui viennent de se terminer/''commit'' et qui quittent le pipeline. Il peut contenir une '''table d'apprentissage''', qui mémorise des informations nécessaires pour faire des prédictions d'adresse. Par exemple, un historique des dernières adresses lues pour les lectures les plus récentes, ou un historique des adresses des lectures. La table est aussi utilisée pour déterminer si les prédictions effectuées étaient correctes ou non. Pour cela, il est possible de réutiliser les techniques vues dans le chapitre sur la prédiction de branchement, notamment des compteurs à saturation. Pour la prédiction de branchement et les autres formes de prédiction mémoire, les table d'apprentissage et pour les instructions/adresses sont fusionnées en une seule. Mais ici, il est nécessaire de les séparer pour une raison assez particulière : éliminer la pollution du cache dans la table de prédiction d'adresse. On ne peut pas allouer une ligne de cache pour chaque nouvelle instruction de lecture exécutée. Seule une minorité des lectures profitent de la prédiction d'adresse. Il est nécessaire de filtrer les lectures pour ne conserver que celles qui profitent de la prédiction d'adresse, ce qui est possible plus facilement en séparant la table d'apprentissage et les compteurs à saturation de la table de prédiction d'adresse. Une première méthode consiste à prédire que la lecture lit toujours la même adresse, ce qui lui vaut le nom de '''prédiction d'adresse constante'''. On pourrait se demander pourquoi une telle situation surviendrait. La raison est qu'il arrive que les compilateurs n'arrivent pas à gérer les accès mémoires efficacement. Dans certaines situations un peu compliquées impliquant des pointeurs ou des références, divers phénomènes complexes d'''aliasing'' des pointeurs peuvent générer des relectures intempestives de données en mémoire. Cela peut aussi arriver lorsqu'on compile du code qui provient de plusieurs librairies, ou quand du code lit régulièrement des constantes en mémoire ROM. La prédiction d'adresse constante ajoute un circuit qui analyse les dernières lectures, une fois qu'elles quittent le ROB, qu'elles ''commit''. Si, pour une instruction de lecture, l'adresse lue est toujours la même, alors l'instruction est ajoutée dans la table de prédiction d'adresse. Vu que les instructions qui accèdent toujours à la même adresse sont rares, il est préférable d'initialiser les compteurs à saturation de façon à ce qu'ils disent que toute nouvelle instruction change d'adresse. Il s'agit d'une méthode simple mais peu efficace, car cette situation est rare. Une autre méthode détecte les accès en enjambées, là encore quand les lectures quittent le ROB. Nous l’appellerons la '''prédiction d'adresse en enjambées'''. De tels accès surviennent régulièrement quand on accède des tableaux ou d'autres structures de données semblables, ce qui rend la technique très intéressante. Implémenter cette technique est facile : il suffit d'ajouter un circuit qui détecte les accès en enjambées, au lieu de celui qui détecte les accès constants. À chaque fois qu'une lecture entraine un succès de cache dans la table de prédiction d'adresse, l'enjambée est ajoutée à l'adresse contenue dans la table de prédiction d'adresse, l'ancienne adresse prédite est remplacée par celle avec l'enjambée d'ajoutée. Sur les Apple M2-M4, la prédiction d'adresse gére aussi bien les adresses constantes que les accès en enjambée. Le brevet qui décrit l'implémentation de l'unité de prédiction d'adresse et de valeur est potentiellement le suivant : [https://patents.google.com/patent/US11829763B2/en Early load execution via constant address and stride prediction]. En théorie, il est possible de faire mieux en repérant des accès mémoires qui se répètent de façon régulière et cyclique, même s'ils n'ont aucune enjambée. Ce genre d'accès se trouve assez souvent lorsque l'on manipule des listes chainées ou des structures de données assez irrégulières comme des arbres, des graphes, etc. Mais il n'y a pas encore de CPU qui implémente de telle optimisation. ===La prédiction de valeur : prédire la donnée lue=== Les techniques de '''prédiction de valeur''' (''Value Prediction'') consistent à prédire quelle est la donnée qui sera lue par une instruction de lecture. Oui, vous avez bien lu : le processeur est capable de parier sur la valeur qui sera chargée depuis la mémoire, de prédire si cette valeur vaut 0, 1, 1024, etc. Une fois son pari fait, il exécute les instructions du programme avec la valeur qu'il a pariée de façon spéculative. Si le pari est correct, le CPU continue l’exécution. Sinon, il est obligé de vider le pipeline et de recommencer avec la bonne valeur. Au premier abord, cette technique semble franchement mal partie tellement les chances de se tromper sont tellement énormes ! Mais le fait est que, dans certains cas, il est possible de spéculer correctement. Là encore, les techniques les plus simples fonctionnent dans deux cas. Le premier est celui où une donnée est relue à l'identique à chaque fois que la lecture est exécutée. Le second est celui où la donnée est incrémentée/décrémentée d'une constante qui est toujours la même d'une exécution de la lecture à l'autre. Une sorte d'enjambée constante, mais pour les données. Nous allons nous concentrer sur le premier cas, celui où une donnée est relue plusieurs fois sans être modifiée entre temps. L'idée de base est qu'une instruction de lecture qui s'exécute plusieurs fois de suite à la même adresse renvoie la même valeur à chaque fois. C'est parfaitement possible s’il n'y a eu aucune écriture à cette adresse entre-temps, d'où le fait qu'il faille surveiller si les prédictions constantes sont correctes. On peut se demander quelles sont les raisons qui font qu'une instruction de lecture renvoie la même valeur à chaque fois. Après tout, autant lire une seule fois la donnée et la garder dans un registre une bonne fois pour toutes ! Mais dans la réalité, on fait face à quelques limites, qui seront détaillées dans la liste suivante. La première est que le manque de registre fait que certaines données sont conservées en RAM et sont relues fréquemment. Cela arrive quand on stocke des constantes en mémoire. Par exemple, sur les processeurs x86, les constantes flottantes ne peuvent pas être intégrées dans nos instructions via le mode d'adressage immédiat. À la place, on les stocke en mémoire et on les charge dans les registres à chaque fois qu'on en a besoin. L'implémentation est similaire à la prédiction d'adresse, si ce n'est que c'est la donnée lue qui est prédite. Là encore, on a une '''table de prédiction de donnée lue''', consultée lorsqu'une lecture est émise. La table de prédiction de la donnée lue est une mémoire cache qui associe le ''Program Counter'' de la lecture à la donnée prédite, éventuellement couplé à des compteurs à saturation. Elle est consultée à chaque fois qu'une lecture est émise, la donnée adéquate est récupérée lors d'un succès de cache. On trouve aussi un circuit qui insère ou retire des instructions de la table de prédiction de donnée lue. Elle détecte les lectures prédictibles, couplé à une table d’apprentissage, et quelques circuits annexes liés au ROB ou aux files de lectures. Elle contient au minimum des compteurs à saturations, associés chacun à une instruction (son ''program counter'' pour être précis). ==L'optimisation du ''Mirroring memory operand''== Les processeurs modernes implémentent une optimisation concernant les accès mémoire. Cette optimisation a été décrite par Agner Fog dans ses manuels d'optimisation, sous le terme de '''''Mirroring memory operand'''''. Il a documenté son apparition sur les processeurs Zen 2 d'AMD, puis son évolution sur les processeurs Zen 4 et 5. L'optimisation est bizarrement absente de la microarchitecture Zen 3. Elle se manifeste dans une condition très précise : plusieurs instructions proches accèdent à la même adresse. Les deux instructions ne sont pas forcément consécutives, il peut y avoir une dizaine ou centaine d'instructions entre les deux. Une telle situation survient souvent sur les CPU CISC, et notamment sur les CPU x86, car ils ont peu de registres. les compilateurs font donc beaucoup d'échanges entre pile d'appel et registres, ce qui fait que des données écrites dans la pile sont souvent relue quelques instructions plus tard. Dans ce cas, la donnée lue/écrite est mémorisée dans un registre interne au processeur et peut être accédée très rapidement. Par exemple, imaginons plusieurs lectures proches à la même adresse, sans écriture entre elles. Dans ce cas, le processeur ne fera qu'une seule lecture en mémoire RAM, les lectures ultérieures liront la donnée depuis le registre interne. Comme autre exemple, imaginons une écriture suivie plus tard par une lecture à la même adresse. Dans ce cas, la donnée sera écrite dans un registre interne, en plus d'être propagée dans le cache ou la RAM, et la lecture ultérieure lira directement le registre interne. Le gain en performance est alors conséquent, comparé à du simple ''store-to-load forwarding''. Il faut noter que la technique marche aussi pour les opérations ''load-op'', pas seulement pour les instructions d'accès mémoire. Par exemple, prenons une écriture en RAM, suivies quelques instructions plus tard par une opération ''load-op'' à la même adresse. L'écriture écrit à une adresse, qui est lue par l'instruction ''load-op''. L'optimisation s'applique : l'instruction ''load-op'' lira l'opérande mémoire depuis un registre interne du processeur, qui contient une copie de la donnée écrite. Sur les processeurs Zen 2, l'optimisation marchait aussi pour les instructions PUSH et POP, qui lisaient/écrivaient dans la pile d'appel. Mais cette possibilité a été retirée sur les processeurs Zen 5. La technique détecte que deux instructions accèdent à la même adresse sous certaines conditions bien précises. L'optimisation ne marche que pour les modes d'adressages indicés et indirect, avec ou sans décalages, mais pas en mode absolu ou relatifs. Il faut préciser que l'optimisation ne marche que pour les registres généraux, pas les registres flottants. Les registres doivent être précisés de la même manière dans toutes les instructions. Par exemple, le processeur ne reconnait pas que l'addition des deux registres RCX + RAX est la même que RAX + RCX. Sur les CPU Zen 2 et 4, elle ne marchait que pour des opérandes de 32 et 64 bits, mais les processeurs Zen 5 gèrent des données de 8 et 16 bits aussi. Les décalages étaient limités à 8 bits sur le Zen 2, mais n'a plus de limites sur les Zen 4 et 5. ==La désambiguïsation mémoire a des effets de bord== La désambiguïsation mémoire a une caractéristique que l'exécution dans le désordre simple n'a pas : elle a des effets qui débordent sur la mémoire. Il est possible de savoir si un processeur fait de la désambiguïsation mémoire en regardant le bus mémoire. On s’aperçoit alors que les lectures se font dans un ordre différent de celui du programme. À l'opposé, l'exécution dans le désordre n'a pas d'effets observables de ce genre. Elle améliore la performance, elle modifie les durées observables des instructions, mais cela se limite à des questions de ''timings''. Le seul moyen de savoir si un processeur fait de l'exécution dans le désordre est de mesurer les latences/durées de chaque instruction. Mais aucun état observable au-delà des ''timings'' n'est modifié. Pas avec la désambiguïsation mémoire. ===La consistance mémoire=== Le fait que la désambiguïsation mémoire ait des effets observables ne pose aucun problème avec un seul processeur, ca elle est conçue pour. Par contre, les techniques de désambiguïsation mémoire peuvent poser des problèmes avec plusieurs processeurs. Il arrive que des programmes partagent une même portion de mémoire, et lisent/écrivent dedans. Avec plusieurs processeurs, chaque processeur effectue la désambiguïsation mémoire dans son coin sans se préoccuper des autres. Les opérations d'écriture peuvent être mises dans le désordre du point de vue des autres processeurs, et les lectures effectuées par les autres processeurs peuvent alors renvoyer de vielles données. Un exemple classique est celui de deux cœurs qui partagent le même cache ou la même mémoire, mais qui utilisent chacun une file d'écriture (''store buffer''). Imaginons que le premier cœur effectue une écriture, puis une lecture, à des adresses différentes. L'écriture est retardée grâce au tampon d'écriture, alors que la lecture lit directement le cache. Pour le premier processeur, l'écriture s'est réalisée avant la lecture, car pour lui, la fin de l'écriture signifie écrire dans le tampon d'écriture. Mais pour le second cœur, l'ordre s'est inversé : il voit la lecture dans le cache, puis l'écriture dans le cache. La raison est qu'il n'a pas connaissance du contenu du tampon d'écriture de l'autre cœur. Un autre exemple : les techniques de prédiction d'adresse et de valeur posent des problèmes avec les dépendances RAW. Elles permettent à une instruction mémoire de s'exécuter avant une autre, malgré la présence d'une dépendance RAW entre les deux. L'ordre entre les deux instructions change, alors que les autres processeurs ne devraient pas le voir. Et dans les faits, les processeurs qui incorporent ces techniques ont des modèles de consistance mémoire qui ne permettent pas ces optimisations en cas de dépendances RAW. Les deux techniques doivent idéalement être désactivées pour les instructions mémoire qui ont des dépendances RAW. Les programmeurs doivent tenir compte de ce genre de situations, dans des cas assez rares et liés à la programmation bas niveau. Pour cela, ils doivent savoir comment le processeur réorganise les accès mémoire. Concrètement, chaque processeur définit un '''modèle de consistance mémoire''' qui indique quelles optimisations sont activées et non. Il s'agit d'une sorte de contrat entre le logiciel et le matériel : les programmeurs savent comment le processeur va réorganiser les lectures et écritures et peuvent coder leurs programmes en fonction. L'implémentation du système de consistance mémoire dépend grandement de l'architecture, mais des modèles ont été standardisés afin de permettre aux programmeurs de savoir où placer les barrières mémoires. Il s'agit de vrais standards, formalisés, parfois mathématiquement, que des chercheurs étudient sur un plan mathématique et algorithmique. Le modèle préféré des programmeurs est la '''consistance séquentielle''', où les différents processeurs effectuent des accès mémoire dans l'ordre du programme et les accès mémoire simultanés sont interdits. La première contrainte interdit l'usage de mécanismes de désambiguïsation mémoire, la seconde interdit l'usage de caches non-bloquants. La consistance séquentielle a donc un cout en performance assez important, ce qui fait que les processeurs modernes n'utilisent pas la consistance séquentielle. Un modèle plus flexible est le '''''total store ordering''''', qui autorise la présence d'une file d'écriture et l'usage de caches non-bloquants pour les lectures (interdit d'avoir plusieurs écritures simultanées). C'est plus ou moins celui utilisé par les processeurs x86, comme on le verra plus bas. Il existe des modèles de consistance mémoire plus relâchés, appelés des '''modèles de consistance faible''', qui autorisent diverses optimisations de spéculation mémoire, la combinaison d'écriture (fusionner deux écritures à la même adresse en une seule), etc. Ils sont supportés sur les architectures ARM et POWER. ===Les barrières mémoire=== Le changement d'ordre des lectures et écritures peut poser occasionnellement des problèmes avec la programmation pour les architectures multi-processeur ou multicœurs. Il existe des morceaux de code qui ne marchent que si la consistance séquentielle est respectée et il faut faire en sorte qu'ils marchent sur des processeurs avec un modèle de consistance mémoire différent. Pour cela, les processeurs incorporent des instructions appelées '''barrières mémoires''' ou '''''Fences'''''. Elles forcent le processeur à terminer tous les accès mémoire qui précédent dans l'ordre du programme. Certains processeurs possèdent une barrière mémoire pour les lectures et une autre pour les écritures. D'autres processeurs ajoutent des barrières mémoires dédiées à des cas particuliers. Elles permettent de forcer les accès à des données partagées à dérouler correctement. [[File:Fences.png|centre|vignette|upright=2|Fences.]] Leur implémentation est assez simple : elles empêchent l'émission de toute nouvelle instruction mémoire, tant qu'une condition précise n'est pas réunie. Par exemple, la barrière mémoire pour les écritures attend que la file d'écriture soit vidée avant de démarrer le moindre accès mémoire. Un compilateur peut placer les barrières mémoires au bon endroit, avec un peu d'aide du programmeur. Certains langages de programmation permettent d'indiquer au compilateur qu'une donnée doit toujours être lue depuis la mémoire RAM, via le mot-clé volatile. C'est très utile pour préciser que cette donnée est potentiellement partagée par plusieurs processeurs ou manipulable par des périphériques. Les compilateurs peuvent placer des barrières mémoires lors des lectures ou écritures sur ces variables, mais ce n'est pas une obligation. Il arrive aussi que le programmeur doive manipuler explicitement des barrières mémoires. Utiliser l'assembleur est alors une possibilité, mais qui est rarement exploitée, pour des raisons de portabilité. Pour limiter la casse, certains systèmes d'exploitations ou compilateurs peuvent aussi fournir des barrières mémoires explicites, encapsulées dans des bibliothèques ou cachées dans certaines fonctions. ===La modèle de consistance des processeurs x86=== Après avoir vu la théorie, passons maintenant à la pratique. Dans cette partie, on va voir les modèles de consistances utilisés sur les processeurs x86, ceux qu'on retrouve dans les PC actuels. Le modèle de consistance des processeurs x86 a varié au cours de l'existence de l'architecture : un vulgaire 486DX n'a pas le même modèle de consistance qu'un Core 2 duo, par exemple. Ils ont évolués au cours du temps, avec l'arrivée de nouvelles optimisations. De plus, leur formalisation a été progressive, et s'est faite au fur et à mesure. En général, les modèles de consistance des processeurs x86 ont toujours étés assez restrictifs, si on les compare aux autres processeurs. Il s'agit de modèles de type ''total store ordering'' (TSO), au moins dans les grandes lignes. Ils permettent donc d'effectuer certaines lectures en avance, soit parce que les lectures se font à des adresses différentes, soit par utilisation de réacheminement écriture vers lecture. Les possibilités d'exécution en avance des lectures varient suivant le processeur, elles deviennent de plus en plus performantes avec le temps. Le premier modèle de consistance est apparu sur les premiers processeurs x86 et est resté en place sur tous les processeurs de marque Pentium. Les processeurs de l'époque n'incorporaient pas beaucoup d'optimisations, le budget en transistor n'était pas assez conséquent pour ça, la mémoire n'était pas assez lente. Le modèle TSO de l'époque n'autorisaient que peu de lectures anticipées. Une lecture ne peut être exécutée en avance que sous les conditions suivantes : * les écritures contournées se font dans la mémoire cache ; * la lecture doit se faire dans la mémoire RAM ; * les écritures se font à une adresse différente de la lecture ; * aucune transaction avec un périphérique ne doit être en cours. A partir du Pentium 4, les choses changent. Le Pentium 4 est en effet le premier processeur à implémenter des techniques permettant d’exécuter plusieurs processus en parallèle, dont l'Hyperthreading. En conséquence, le modèle de consistance a dû être assoupli pour éviter de perdre bêtement en performance. Il s'agit d'un vrai modèle de type TSO, la majeure partie des contraintes sur les lectures anticipées sont réduites au minimum. Il y a aussi des contraintes supplémentaires pour les instructions dites atomiques, ainsi que pour les instructions qui accèdent aux périphériques ou aux entrées-sorties. Une optimisation séparée du TSO est que les écritures en mémoire peuvent changer d'ordre dans un cas exceptionnel : les écritures effectuées par les instructions de copie mémoire comme REPMOVSD, REPSCACB, ainsi que les instructions SSE MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, MOVNTPD. Les écritures effectuées dans ces instructions peuvent se faire dans un désordre complet (ou presque). Lors du passage au 64 bits, le modèle mémoire des processeurs x86 de l'époque a été formalisé dans un papier d'Intel appelé [https://www.cs.cmu.edu/~410-f10/doc/Intel_Reordering_318147.pdf Intel® 64 Architecture Memory Ordering White Paper], mis à jour en 2008 suite à quelques problèmes techniques. La formalisation du modèle a été d'une grande aide aux programmeurs, qui devaient se débrouiller avec des documentations qui ne formalisaient pas de modèle de consistance précis. Le document décrit un modèle de consistance mémoire portant le nom barbare de ''total lock order + causal consistency” (TLO+CC)'', qui admet plus d'optimisations que le modèle TSO. Mais la description était en réalité légèrement différente du modèle mémoire réellement implémenté sur les processeurs existants, qui utilisaient du TSO, sauf en de rares occasions. Les concepteurs de processeur font souvent cela, à savoir définir un modèle de consistance plus laxiste que réellement implémenté, pour des questions de comptabilité. Ainsi, si les futurs processeurs de la marque implémentent un modèle plus laxiste, les programmes déjà codés continuent de fonctionner. Le modèle décrit dans la documentation laisse de la marge pour le futur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le parallélisme mémoire | nextText=Le parallélisme mémoire }} </noinclude> 3lft6834wfcqticdljel6cbl36dxxd0 774135 774134 2026-10-04T13:51:39Z Mewtow 31375 /* L'exécution anticipée des calculs d'adresse */ 774135 wikitext text/x-wiki Dans les chapitres précédents, nous avons vu la mal-nommée exécution dans le désordre dans les chapitres précédents, qui devrait plutôt s'appeler l'émission dans le désordre. Elle modifie l'ordre des instructions pour gagner en efficacité, mais cela n'est valable que pour les instructions travaillant sur des registres ! Nous n'avons pas parlé de l'exécution dans le désordre des accès mémoire, car ils sont un peu à part. Et pour comprendre pourquoi, nous allons dédier ce chapitre à la gestion des accès mémoire dans le pipeline. Avant de poursuivre, faisons un rappel rapide sur les dépendances de données. Il faut distinguer deux types de dépendances de données : les dépendances de registres (deux instructions manipulent le même registre), et les dépendances d'adresse (deux instructions manipulent la même adresse). Deux instructions ont une dépendance d'adresse si elles écrivent ou lisent la même adresse mémoire. Des instructions mémoires qui lisent/écrivent des adresses différentes sont indépendantes. Lorsque deux instructions mémoire lisent/écrivent à la même adresse, on parle d''''aliasing mémoire'''. Formellement, l'aliasing mémoire n'est ni plus moins que l'application des dépendances de données aux instructions mémoire. Et comme les autres dépendances de données, il existe plusieurs types d'aliasing mémoire : RAR, RAW, WAR et WAW. Les dépendances RAR ne posent aucun problème, les lectures peuvent changer d'ordre sans aucun problème, tant qu'il n'y a pas d'écritures entre les deux. Par contre, les autres dépendances imposent trois contraintes. * Les dépendances RAW imposent que les lectures ne doivent pas s'exécuter avant une écriture à la même adresse. * Les dépendances WAR imposent que l'on ne peut pas déplacer une écriture avant une lecture à la même adresse. * Enfin, les dépendances WAW font que l'ordre des écritures doit être celui du programme : impossible de changer l'ordre de deux écritures. Les trois contraintes peuvent s'implémenter de plusieurs manières différentes. Comme pour les dépendances de données normales, seules les dépendances RAW sont de vraies dépendances de données. Les deux autres dépendances viennent du fait que l'on utilise la même adresse pour stocker des valeurs différentes. Détecter les cas d'''aliasing'' mémoire est primordial pour gérer la désambiguïsation mémoire, et cela demande de comparer des adresses. S'il existe des techniques similaires au renommage de registre permettent de les faire disparaitre, elles sont très limitées. Diverses optimisations permettent d'émettre des accès mémoire dans le désordre, exactement comme l’exécution dans le désordre le fait pour les autres instructions. Elles utilisent pour cela des ''schedulers'' spécialisées dans les accès mémoire. Il faut vraiment insister sur le fait que l'exécution dans le désordre proprement dite ne se préoccupe pas des micro-opérations mémoire. Pour faire la distinction, on parle de '''désambiguïsation mémoire''' (''memory disambiguation'') pour parler de l'exécution dans le désordre des accès mémoire. Le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. La raison est que la donnée lue est utilisée par d'autres instructions, dépendantes de la lecture. Elles sont donc à la tête d'une chaine de dépendances de données qui peut bloquer le pipeline si elle n'est pas résolue très tôt. Dès qu'une lecture peut être lancée, elle accède directement au cache de données. Par contre, si l'''aliasing'' ne le permet pas (écriture à la même adresse pas encore effectuée) ou que l'adresse à lire n'a pas encore été calculée, la lecture attend son tour. Le problème est que la désambiguïsation mémoire demande de comparer des adresses, pour détecter les dépendances d'adresse. Et cela pose de sérieuses contraintes pour son implémentation. L'exécution dans le désordre normale compare des registres, ce qui est beaucoup plus simple. Les registres utilisés sont directement encodés dans l'instruction elle-même, ce qui fait qu'on peut détecter les dépendances de registre après le décodage. Et elles peuvent même être éliminées par le renommage de registre pour les dépendances WAW et WAR. Par contre, les adresses ne sont pas encodées dans l'instruction elle-même (sauf avec le cas particulier de l'adressage absolu), mais sont lues depuis les registres ou calculées par une unité de calcul. Les adresses ne sont donc connues qu'une fois que l'instruction a attendu suffisamment de temps dans le ''scheduler''. ==L’émission dans l'ordre des accès mémoire== Avant toute chose, faisons quelques rappels sur l'exécution dans le désordre "normale", sans désambiguïsation mémoire. Les micro-opérations renommées sont mises en attente dans un ''scheduler'', le temps que leurs opérandes soient disponibles. Les µops mémoire font cependant exception. ===La file de µops mémoire=== Avec l''''émission dans l'ordre des accès mémoire''', le processeur émet les accès mémoire dans l'ordre du programme. Cela implique que l'unité mémoire n'a pas de ''scheduler'', ni de station de réservation associée. À la place, les micro-opérations mémoire sont placées dans une file de micro-opération dédiée, appelée la '''file de µops mémoire'''. La file de µops mémoire est une mémoire FIFO, dont chaque entrée peut accueillir une µops mémoire. [[File:Unité de gestion des accès mémoires dans l’ordre.png|centre|vignette|upright=2.0|Unité de gestion des accès mémoires dans l’ordre]] Par contre, le processeur utilise des ''schedulers'' pour les unités de calcul. Autant les accès mémoire se font dans l'ordre, autant les instructions arithmétiques/logiques/branchements/autres sont exécutées dans le désordre. En clair, le processeur supporte l'exécution dans le désordre, sauf pour les micro-opérations mémoire. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] Vu de l'extérieur, il faut attendre qu'un accès mémoire soit terminé pour lancer le suivant. Avant d'émettre une micro-opération mémoire, la file de micro-opération (son ''scoreboard'') vérifie si l'unité d'accès mémoire est libre ou non. Si elle est occupée, un accès mémoire est en cours, et on ne peut pas émettre de micro-opération mémoire. Une fois l'accès mémoire terminé, la mémoire ou le cache envoient un signal pour indiquer que l'accès est fini, et c'est au tour d'une nouvelle micro-opération mémoire. L'unité mémoire a un port sur lequel on envoie la micro-opération émise, que ce soit une écriture ou une lecture. Une file de µops implique la ''lecture après émission'', à savoir que les µops mémoire quittent la file de µops, avant de lire les registres adéquats. Il y a donc une connexion entre banc de registre et unité mémoire, comme sur n'importe quel autre processeur. Mais la file de µops n'est pas connectée au banc de registre, ni au réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] L'organisation précédente laisse penser que les calculs d'adresse sont forcément réalisés dans l'unité mémoire. Mais ce n'est pas une obligation. Il est parfaitement possible de scinder une µops mémoire en deux : une µop mémoire proprement dite et une µop de calcul d'adresse. La liaison entre les deux doit cependant se faire en utilisant un registre. L'adresse calculée est enregistrée dans un registre d'adresse "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. ===La file d'écriture : une FIFO de mise en attente des écritures=== L'unité mémoire exécute une µops mémoire à la fois. Avec un détail particulier : les écritures doivent attendre que toutes les instructions se terminent, avant de pouvoir s'exécuter. Sans ça, les exceptions précises sont impossibles, la prédiction de branchement l'est aussi et l'exécution dans le désordre aurait des conséquences fâcheuses (en exécutant des écritures en avance). Précisément, les écritures doivent respecter deux contraintes : s'exécuter dans le tout dernier étage du pipeline, s'exécuter dans l'ordre du programme. Il serait possible de bloquer l'émission des écritures tant que le ROB ne donne pas son accord. Mais ce n'est pas du tout ce qui est fait. A la place, les écritures sont mises en attente dans une mémoire FIFO, appelée la '''file d'écriture''', ou encore la ''Write Queue''. Les écritures sont mises en attente, jusqu'à ce que le ROB leur donne le okay pour transfer l'écriture en mémoire RAM. Nous en avions déjà parlé dans le chapitre sur le pipeline, dans la section sur les exceptions précises. Mais quelques rappels ne feront pas de mal. L'écriture est insérée dans la file d'écriture lorsqu'elle est émise, elle en sort à l'étage de ''Writeback'' si tout se passe bien. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente, faites à tort. De plus, la file d'écriture est une mémoire FIFO, qui mémorise les écritures dans l'ordre du programme. En effet, les écritures sont ajoutées dans la file d'écriture lorsqu'elles sont émises, et elles sont émises dans l'ordre du programme. [[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Write Queue sur un pipeline fixe]] La file d'écriture garantit que les écritures sont dans l'ordre du programme. Et cette propriété élimine totalement les dépendances WAW. Le fait que les écritures se font à la fin du pipeline élimine quant à elle les dépendances WAR. Il ne reste plus qu'à gérer les dépendances RAW, celles où une lecture lit une donnée écrite par une autre instruction. Une telle situation survient, car les écritures sont mises en attente pendant un certain temps, la donnée écrite peut être lue pendant ce laps de temps. La lecture ne peut pas lire la donnée dans le cache, car la donnée à lire n'y est pas encore enregistrée. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire, mais il existe. Il se manifeste surtout sur les processeurs avec peu de registre, où des données doivent régulièrement être déplacées temporairement en mémoire RAM. Pour éviter cela, une solution consiste mettre en attente les lectures si une écriture est en attente. Les lectures sont maintenues dans la file de µops tant que la file d'écriture n'est pas vide. La file de µops mémoire est suivie d'un circuit de détection des dépendances, un '''''scoreboard'' mémoire''' qui est assez simple. L'unité mémoire fournit un bit qui indique si la file d'écriture est vide ou non, ainsi qu'un second bit qui indique qu'elle est vide. Les deux ne sont pas compliqués à générer, ils font partie intégrante de l'interface de toute mémoire FIFO digne de ce nom. Le ''scoreboard'' mémoire utilise ces deux bits pour savoir s'il peut émettre une micro-opération mémoire. Il n'émet pas de lecture tant que la file d'écriture n'est pas vide. Il peut par contre émettre des écritures tant qu'elle n'est pas pleine. Une meilleure solution serait d'interdire les lectures, mais seulement en cas de dépendance mémoire détectée. Et c'est là qu'intervient la technique du '''''load bypassing'''''. Elle autorise une lecture, si elle n'a aucune dépendance avec les écritures mises en attente dans la file d'écriture. Elle doit être mise en attente dans le cas contraire. Pour l'implémenter, il faut comparer l'adresse à lire avec toutes les adresses dans la file d'écriture. La lecture est exécutée immédiatement si aucune correspondance n'est trouvée, mais elle est mise en attente dans le cas contraire. Pour cela, la file d'écriture est modifiée et devient un mélange entre FIFO et mémoire associative. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'' : la lecture doit être bloquée. Sinon, c'est un défaut de ''Write Queue'', la lecture accède au cache, là où se trouve la donnée à lire. Dans le chapitre sur le contournement, nous avons parlé d'une forme de contournement qui corrige ce problème. Les lectures peuvent lire la donnée depuis la file d'écriture si besoin, ce qui renvoie la donnée adéquate. Pour cela, les lectures consultent à la fois le cache et la file d'écriture, comme dit plus haut. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'', ou encore de '''réacheminement écriture-vers-lecture'''. [[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]] La lecture est mise en attente dans l'unité mémoire, vu que c'est elle qui fait les comparaisons d'adresse. Du point de vue extérieur, le ''scoreboard'' mémoire ne voit pas s’il y a eu succès dans la file d'écriture ou non. Il voit juste que l'unité mémoire est occupée et ne peut pas accepter de nouvelle lecture. Elle reste occupée durant quelques cycles, en cas de défaut de ''Write Queue'', le temps de faire la lecture dans le cache. Par contre, en cas de succès de ''Write Queue'', elle reste occupée tant que la lecture n'a pas donné de résultat, ce qui inclue le temps de mise en attente. Les processeurs haute performance implémentent tous le ''Store to load Forwarding'', mais quelques processeurs basse performance ne le font pas. Ils se contentent du ''load bypassing'', qui offre des performances correctes pour un cout en matériel bien plus modeste. Pour donner un exemple, la micro-architecture Jaguar d'AMD était dans ce cas. Elle a été utilisée sur les consoles de jeu de 8ème génération, comme la Xbox One et la Playstation 4. Les processeurs haute performance préférent utiliser plus de circuits pour implémenter le ''Store to load Forwarding'', mais l'implémentation n'est souvent pas complète. En effet, l'implémentation est souvent simple et ne prend en compte que des lectures/écritures à des adresses identiques, sans compter que la taille des données doit aussi être identique. Une écriture d'un entier de 32 bit sera contournée et envoyée à une lecture de 32 bits à la même adresse. Mais la lecture lit seulement 16 bits sur les 32 écrits, le contournement peut ne pas marcher. Concrètement, ce cas précis fonctionnera. Mais le cas inverse, avec une lecture lisant plusieurs écritures de taille plus petite, ne marchera pas sur tous les processeurs. Ne parlons pas du cas où les données écrites sont à cheval sur deux lignes de cache. ==L'exécution anticipée des calculs d'adresse== L'exécution dans le désordre fonctionne mieux si les lectures sont exécutées le plus tôt possible. Les lectures lisent des données qui sont utilisées par d'autres instructions. Si la lecture tarde, les autres instructions sont mises en attente. Exécuter les lectures le plus tôt possible est donc crucial. Mais comment faire ? Un moyen pour ça est de faire en sorte que les adresses à lire/écrire soient connues le plus tôt possible. Mais cela implique que les calculs d'adresse ne sont pas fait en même temps que l'accès mémoire. Pour cela, il est possible de séparer les µops mémoire en deux micro-instructions : une pour le calcul d'adresse et l'autre pour les accès mémoire. On parle de '''calcul anticipé des adresses'''. Le calcul anticipé des adresses est utilisé avec ou sans désambiguïsation mémoire. Elle a des avantages avec et sans. Sans, elle calcule les adresses en avance, ce qui fait que des lectures/écritures mises en attente ont leurs opérandes prêtes plus rapidement. Avec, cela permet de connaitre les adresses en avance, ce qui permet de détecter plus rapidement les dépendances d'adresse. Mais laissons cela pour plus tard. ===La ''Load/Store Queue'' : une file de µops améliorée=== Implémenter cette technique demande de faire deux choses. Premièrement, il faut faire les calculs d'adresse en-dehors de l'unité mémoire, soit dans une ALU entière, soit dans une unité de calcul d'adresse dédiée. Deuxièmement, les adresses calculées doivent être mises en attente quelque part, et être liées facilement à la µop mémoire associée. Pour le second point, la file de µops mémoire parait être un bon candidat, mais n'est pas exactement la solution. Une file de µops au sens strict ne mémorise pas les opérandes des µops, donc pas d'adresse. Cela implique la lecture après émission, ce qui veut dire que l'adresse à lire/écrire est lue depuis les registres une fois que la µops sort de la file de µops (idem pour la donnée à écrire). En pratique, cette implémentation rend difficile de faire les calculs d'adresse ailleurs que dans l'unité mémoire. Heureusement, il est possible d'adapter la file de µops pour gérer la lecture avant émission. L'idée est qu'elle mémorise, pour chaque µop mémoire, l'adresse à lire/écrire et d'autres informations. Il ne s'agit donc plus vraiment d'une file de µops mémoire mais quelque chose d'autre, une sorte d'intermédiaire entre une station de réservation et une file de µops. D'un côté, elle fonctionne comme une file de µops car elle émet les µops dans l'ordre du programme. De l'autre, elle mémorise les opérandes des µops mémoire, ce qui en fait une pseudo-station de réservation. Une telle structure est appelée une '''''Load/Store Queue''''' (LSQ). Une LSQ contient plusieurs entrées, chaque entrée pouvant accueillir une µops mémoire et ses opérandes. Une entrée contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures. Les deux derniers champs sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. Elle contient aussi des bits de validité, qui indique si l'adresse ou la donnée sont disponibles, ou si le champ associé est vide. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2|Load Store Queue unifiée]] La LSQ doit faire le lien entre une adresse calculée par l'ALU entière, et l'entrée associée de la LSQ. Pour cela, l'unité d'émission peut associer un '''identifiant LSQ''' à une µops mémoire. L'identifiant est généré à l'émission. Aussi bien la µops de calcul d'adresse que a µop mémoire ont toutes deux le même identifiant. L'identifiant est propagé avec la µop de calcul d'adresse, comme tout signal de commande. En sortant de l'AGU, l’identifiant est transmis à la LSQ en même temps que l'adresse. La LSQ regarde quelle entrée a cet identifiant, et mémorise l'adresse dedans. La gestion des données à écrire se fait elle avec le système de contournement normal. La LSQ sait dans quel registre se trouve la donnée à écrire, elle surveille juste le système de contournement pour détecter quand la donnée est disponible. ===Le contournement des adresses mémoire avec une LSQ=== Avec une LSQ, les registres sont lus quand la µop mémoire est insérée dans la LSQ, si elles sont disponibles. Si elles ne sont pas encore disponibles, elles seront envoyées par le système de contournement plus tard. Cela implique qu'il y ait un système de contournement, qui relie les ALUs entières à la LSQ. Les AGUs envoient leur résultat à la LSQ, qui place alors l'adresse calculée dans la bonne entrée, la bonne "µop". [[File:Load-Store Queue.png|centre|vignette|upright=2|Load-Store Queue]] Une autre solution utilise des ALU spécialisées dans les calculs d'adresse, appelées des ''Adress Generation Unit'', abrévié AGU. L'avantage est que cela simplifie grandement le réseau de contournement : seules les AGU sont connectées à la LSQ. Un autre avantage est lié à l'adressage de la destination. En utilisant des opérations entières usuelles, il faudrait préciser que le résultat est à destination de la LSQ, pour configurer le réseau de contournement. Avec des AGU, les calculs d'adresse sont des micro-opérations différentes des additions et soustraction, elles sont envoyées au AGU spécifiquement, pas besoin de préciser la destination. [[File:ALU et LSQ.png|centre|vignette|upright=2|AGUs et Load-Store Queue.]] Dans les deux cas, les µops de calcul d'adresse sont alors considérées comme des opérations entières un peu spéciales, gérées par le système d'exécution dans le désordre. Elles peuvent être émises dans le désordre, mais la LSQ se chargera de faire les accès mémoire dans l'ordre. Il y a donc calcul des adresses dans le désordre, mais accès mémoire dans l'ordre. ==Les ''schedulers'' mémoire unifiés== Avec la technique précédente, les µops mémoire s'exécutent dans l'ordre, à savoir que l'on ne peut pas déplacer une lecture avant ou après une autre. Il arrive que des lectures prêtes soient mises en attente, parce qu'une lecture précédente attend ses opérandes. Et ce retard sur les lectures se répercute sur les instructions lecture-dépendantes, donc sur le reste du pipeline.Les conséquences sur les performances sont assez importantes. Mais avec l'implémentation qui va suivre, il est possible exécuter les lectures dans le désordre tant que les conditions d'''aliasing'' sont respectées. L'implémentation remplace les files de µops mémoire par un ''scheduler'' spécialisé dans les µops mémoire. Il sera appelé le '''''scheduler'' mémoire''' dans ce qui suit. [[File:Processeur avec émission dans le désordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans le désordre des accès mémoire]] Une entrée du ''scheduler'' mémoire contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures (inutile pour les écritures). Elle contient aussi des bits de validité pour chaque champ qui indiquent si l'adresse d'accès est connue, ou si la donnée à écrire est disponible pour une écriture. Les champs pour la donnée à écrire ou le registre de destination sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2.5|''Load/Store Queue''.]] Lorsqu'une micro-opération mémoire est émise, elle est insérée dans le ''scheduler'' mémoire. Si l'adresse à lire/écrire est disponible, elle est immédiatement lue depuis les registres, idem pour une éventuelle donnée à écrire. Sinon, la micro-opération attend dans le ''scheduler'' mémoire que son adresse soit calculée par le processeur. ===Les calculs d'adresse avec un ''scheduler'' mémoire=== Le ''scheduler'' mémoire détecte des dépendances d'adresse entre instructions. Pour cela, il compare toutes les adresses à lire/écrire. Il mémorise donc les dites adresses, ce qui en fait une station de réservation. Et cela implique que ces adresses ont été calculées ailleurs, dans une ALU entière, ou dans une AGU séparée de l'unité mémoire. L'implémentation la plus simple effectue les calculs d'adresse dans les ALU entières. La sortie de toutes les ALU entière est reliée au ''scheduler'' mémoire. [[File:Contournement pour la file de µops mémoire.png|centre|vignette|upright=2|''scheduler'' mémoire]] Une autre implémentation utilise des unités de calcul d'adresse dédiées, afin de simplifier le réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse via AGU.png|centre|vignette|upright=2|ALU et ''scheduler'' mémoire]] Une conséquence est que l'implémentation impose que toutes les instructions mémoire sont découpées en deux µops : une µop pour calculer l'adresse, et une µop mémoire. C'est un défaut assez important, car une instruction mémoire prendra deux places dans le ROB, dans les stations de réservation, etc. Vu que les instructions mémoire sont très fréquentes, la capacité du ROB/autres est alors fortement réduite. Il faut noter que les adresses calculées dans l'ALU/AGU doivent atterrir dans l'entrée adéquate, celle de la µops qui doit recevoir l'adresse. La seule solution est que le ''program counter'' de la µops est transmis dans le pipeline, propagé avec la µops de calcul d'adresse. Quand le résultat est calculé, le ''program counter'' associé est envoyé avec au ''scheduler'' mémoire. Le ''scheduler'' mémoire enregistre alors l'adresse dans l'entrée qui est taguée avec cette adresse. ===La détection des dépendances mémoire=== Le ''scheduler'' mémoire intègre un système de détection des dépendances, qui est bien différent de celui d'une station de réservation normale. Les ''schedulers'' mémoire comparent non pas des noms de registres, mais des adresses/opérandes. Pour détecter les dépendances, le ''scheduler'' mémoire compare les adresses à lire/écrire. Pour les µops dont l'adresse est inconnue, le cas dépend de l'implémentation. La version la plus conservatrice suppose qu'elle a des dépendances possibles avec toutes les autres. Chaque micro-opération en attente compare son adresse avec celle de toutes les micro-opérations précédentes. Ou presque, la comparaison ne doit tenir compte que des écritures, et de celles situées avant dans l'ordre du programme. Le ''scheduler'' mémoire doit donc mémoriser les instructions dans l'ordre du programme, tout en faisant des comparaisons d'adresse. Ça en fait une sorte hybride entre mémoire associative et mémoire FIFO. Nous avions vu dans le chapitre "L'exécution dans le désordre" qu'il y a plusieurs manières pour implémenter une station de réservation : avec une mémoire associative, avec préplanification, avec des matrices de dépendances. Le problème est qu'on ne peut pas utiliser une mémoire associative pour le ''scheduler'' mémoire, qui utilise des comparateurs pour comparer les noms de registres/adresses. En effet, la détection des dépendances mémoire demande de faire beaucoup de comparaisons : chaque adresse est comparé à toutes les autres. Et les adresses faisant facilement 32 à 64 bits, les comparateurs sont bien plus imposants que ceux qui comparent des noms de registres de 5-10 bits. Le cout en matériel serait trop élevé, de même que le temps de calcul des dépendances. À la place, on utilise des matrices de dépendances. La détection des dépendances mémoire utilise précisément deux matrices de ce type : une matrice de disponibilité, et une matrice de dépendance. Ces matrices forment une espèce de tableau carré, organisé en lignes et en colonnes. L'instruction dans la énième entrée se voit attribuer la énième ligne et la énième colonne. À l'intersection d'une ligne et d'une colonne, on trouve un bit qui indique si l'instruction de la ligne et celle de la colonne ont une dépendance. La '''matrice de disponibilité''' permet de bloquer l'exécution des lectures/écritures tant qu'une instruction précédente n'a pas calculé son adresse, tant qu'on ne peut pas vérifier les dépendances. Lorsqu'une instruction est ajoutée dans le ''scheduler'' mémoire, elle met tous les bits de la colonne attribuée à 1 si l'adresse à lire/écrire est inconnue, puis les met à 0 une fois l'adresse calculée. Une instruction ne peut pas démarrer si, sur la ligne qui lui est attribuée, se trouve un 1. Plus précisément, si on trouve un 1 pour les instructions plus anciennes, ce qui fait qu'une partie de la matrice est ignorée. Les bits notés X dans le tableau suivant sont ignorés, car ils précisent la disponibilité des opérandes d'un opérande ultérieure, ce qui est inutile. En clair, les calculs d'adresse modifient les colonnes, et on vérifie les lignes pour autoriser l'exécution. {|class="wikitable" |- ! Instructions mémoire dans la LSB, triées de la plus ancienne à la plus jeune ! 0 !! 1 !! 2 !! 3 !! 4 !! 5 !! 6 |- ! 0 | 1 || X || X || X || X || X || X |- ! 1 | 0 || 0 || X || X || X || X || X |- ! 2 | 0 || 1 || 0 || X || X || X || X |- ! 3 | 0 || 1 || 1 || 1 || X || X || X |- ! 4 | 0 || 0 || 0 || 0 || 1 || X || X |- ! 5 | 0 || 0 || 1 || 0 || 0 || 1 || X |- ! 6 | 1 || 1 || 1 || 1 || 1 || 1 || 0 |} Les '''matrices de dépendances''' fonctionnent sur le même principe, mais déterminent les dépendances mémoire. Lorsque le processeur a calculé une adresse, il la compare avec les adresses à lire/écrire déjà dans le ''scheduler'' mémoire. Il met alors à jour les bits de la colonne de la matrice de dépendance : le bit à l'intersection d'une ligne et d'une colonne est mis à 0 si les deux instructions n'ont pas de dépendances, à 1 si elles en ont une. Une instruction mémoire est exécutée si tous les bits de sa ligne sont à 0. Là encore, on ne tient compte que des bits pour les instructions plus anciennes, la matrice a encore une fois une forme triangulaire, comme pour la matrice de disponibilité. Une optimisation scinde le ''scheduler'' mémoire en deux structures, aux rôles légèrement différents. La première gère la disponibilité des opérandes, la seconde gère seulement les dépendances mémoire. La première est une station de réservation où les instructions sont mises en attente tant que leurs opérandes ne sont pas prêts. La seconde met en attente des micro-opérations dont les opérandes sont prêts, mais qui doivent attendre pour une autre raison : dépendance mémoire, cache occupé, etc. L'avantage est qu'on remplace une grosse station de réservation par deux plus petites, mais l'intérêt est surtout relié au système de détection des dépendances. Sans cette optimisation, on a deux matrices énormes reliées à une ''Load/Store Queue'' unique. Avec la séparation, la matrice de disponibilité est reliée uniquement à la première station de réservation, alors que la matrice de dépendance est reliée uniquement à l'autre. Les deux matrices sont donc plus petites, car reliées à deux stations de réservation mémoire plus petites. ==L'exécution spéculative des accès mémoires== Pour rappel, le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. Les données lues sont en effet, le départ d'une série d'instructions dépendantes, généralement des instructions arithmétiques, mais parfois d'autres lectures. Quand on manipule des structures de données, il n'est pas rare qu'une lecture lise un pointeur-adresse utilisé par une autre lecture. Les techniques de désambiguïsation mémoire que nous avons vu précédemment sont dites conservatrices, à savoir qu'elles ne changent pas l'ordre de deux instructions mémoire si elles ont une dépendance d'adresse. Elles exécutent une lecture le plus tôt possible, mais seulement si on sait que celle-ci n'aliase pas d'écritures précédentes. Et cela demande de connaitre les adresses des écritures précédentes dans l'ordre du programme. Si une lecture est précédée par une ou plusieurs écritures, les adresses à écrire doivent être connues. Il se peut en effet qu’une écriture dont on ne connait pas l'adresse aliase la lecture. La garantie est liée à la présence de la file de µops mémoire ou la file de µops STORE. Si une seule écriture a une adresse inconnue, elle reste dans la file de µops mémoire et bloque l'émission de toutes les autres micro-opérations mémoire, lecture comme écriture. Mais il existe des techniques dites spéculatives, qui modifient l'ordre des accès mémoire sans se soucier de leurs dépendances. Elles émettent les lectures dès que leurs opérandes sont prêts, sans se préoccuper de l'aliasing avec une écriture précédente. Pour éviter tout problème, le processeur vérifie si aucune dépendance mémoire n'est violée et corrige le tir dès qu'une violation est détectée. Le processeur vérifie s'il a fait une erreur de prédiction, en vérifiant les adresses à écrire ou lire. En cas d'erreur, le processeur fait comme lors d'une prédiction de branchement ratée : il remet le pipeline en ordre et ré-exécute les accès mémoire dans le bon ordre. Il s'agit d'un cas particulier d’'''exécution spéculative''', au même titre que la prédiction de branchement. Toutes les techniques de ce genre exécutent des lectures dans le désordre. L'avantage est que cela permet d'exécuter les lectures en avance. Vu que la donnée lue est l'opérande d'autres instructions, les exécuter en avance fait prendre de l'avance à un paquet d'instructions. Mais il faut comparer cet avantage au risque de mauvaise prédiction. Et aussi bizarre que cela puisse paraître, cela donne de bons résultats. Il faut dire que les situations où une lecture relit une donnée tout juste écrite sont rares : même pas 2 à 3 % des lectures. Les dépendances mémoire sont donc très rares, les cas d'''aliasing'' sont vraiment limités. En termes de cout en transistors, le hardware qui vérifie les dépendances mémoire reste globalement le même à peu de choses près, il est juste déplacé dans le pipeline et adapté à sa nouvelle fonction. ===La file de lectures=== La technique que nous allons voir émet les lectures dans l'ordre, ce qui simplifie énormément la gestion des dépendances. Les lectures sont émises dans l'ordre, les écritures aussi. Par contre, l'ordre entre lectures et écritures n'est pas respecté. Une lecture peut passer avant une écriture sans problèmes, voire avant plusieurs écritures d'un seul coup. Une telle lecture exécutée en avance d'une autre écriture est appelée une '''lecture spéculative'''. Une lecture spéculative peut donc sauter devant une ou plusieurs écritures, qui seront appelées des '''écritures sautées'''. Une écriture sautée est une précédant une lecture spéculative, dans l'ordre du programme. Évidemment, il se peut qu'une écriture sautée écrive la donnée qui est lue par la lecture spéculative. Il y a alors une violation de dépendance mémoire. Mais le processeur suppose qu'il n'y a pas de dépendantes de ce type et émet la lecture en avance. Rassurez-vous, il surveille quand même la situation, pour tout annuler ensuite s'il détecte une violation de dépendance mémoire. J'ai dit plus haut que le processeur émet les lectures dans l'ordre, ainsi que les écritures dans l'ordre. Pour cela, la LSQ est scindée en deux files de µops : une pour les µops LOAD et une autre pour les µops STORE. Les cours d'architecture des ordinateurs nomment souvent ces deux files en utilisant les termes de ''file de STORE'' (''store queue'') et de ''file de LOAD'' (''load queue''), mais il y a un risque de confusion avec la file d'écriture vue précédemment, et la file de lecture qu'on introduira dans la suite. Dans ce qui suit, nous parlerons de '''file de µops LOAD''' et de '''file de µops STORE''', pour éviter toute confusion. Les deux files sont chacune connectées à l'unité mémoire, via un port dédié. L'unité mémoire a donc un port pour les lectures, et un port pour les écritures. Le port d'écriture est relié à la file d'écriture, le port de lecture est directement relié au cache de données. Il faut noter qu'une unité mémoire à deux ports n'est pas si bizarre que cela. En effet, le cache de données a lui aussi deux ports, dans la majorité des cas, avec un port de lecture et un port d'écriture. [[File:Load Ordering, Store Ordering.jpg|centre|vignette|upright=2|Exécution dans le désordre des lectures.]] Maintenant, voyons comment sont détectées les violations de dépendances mémoire. Les dépendances qui peuvent être violées dans ce cas de figure se font entre une écriture sautée et une lecture spéculative. Si une lecture spéculative lit une donnée qui est écrite par une écriture sautée, alors une dépendance RAW a été violée. Pour les détecter, le processeur garde une trace des lectures spéculatives; tant que les instructions précédentes ne sont pas terminées. Pour cela, il utilise une file d'attente, qui mémorise les lecture spéculatives. Nous l'appellerons la ''file de vérification des lectures'', ou encore la '''file de lectures'''. Elle est une sorte d'équivalent de la file d'écriture, mais pour les lectures. Les lectures sortent de la file µops LOAD dès que leurs adresses sont calculées, sans tenir compte des dépendances mémoires. Elles sont alors ajoutées à la file de lectures et s'exécutent immédiatement, de manière spéculative. Pour vérifier qu'il n'y a pas d'erreur de spéculation, le processeur conserve l'adresse de la lecture effectuée dans la file de lectures. Les lectures quittent la file de lecture quand toutes les instructions précédentes dans l'ordre du programme sont terminées, à savoir tant quand elles ont quitté le tampon de réordonnancement/ROB. Notons que la file de vérification des lectures contient les lectures dans l'ordre du programme. En effet, les lectures sont accumulées en sortant de la file de µops LOAD, où elles sont triées dans l'ordre du programme. Et cela permet de gérer les erreurs de prédiction parfaitement, en annulant les instructions suivant une erreur de prédiction. [[File:Spéculation avec une file de lecture.jpg|centre|vignette|upright=2.5|Spéculation avec une file de lecture.]] Reste à détecter les erreurs de prédiction, ce qui peut être fait avec deux méthodes. La première détecte les violations de dépendances mémoire à chaque écriture. L'idée est que chaque écriture vérifie si une lecture anticipée n'a pas lue la donnée écrite en avance. Pour cela, quand une écriture est émise, elle consulte la file de lecture pour voir s'il y a un conflit d'adresse. La vérification fait cependant gaffe à ne prendre en compte que les lectures situées après l'écriture dans l'ordre du programme. Précisément, elle envoie l'adresse à écrire à la file de lecture et cherche si une lecture ultérieure a eu lieu à la même adresse. Si c'est le cas, cela signifie qu'une lecture anticipée aurait du lire la donnée actuellement écrite, mais que la lecture a été exécutée trop en avance. La lecture n'a pas lu la bonne donnée, il y a eu une violation de dépendance mémoire. Pour faire cette vérification, la file de lecture est un mélange entre FIFO et cache/mémoire associative, sur le même modèle que la file d'écriture. La différence est que la consultation de la file de lecture ne sert pas à faire du contournement, mais à détecter les dépendances. Quand une dépendance est détectée, l'écriture se fait normalement. Par contre, la lecture marquée comme invalide dans la file de lecture. L'exécution du programme se poursuit, jusqu'à ce que la lecture arrive à l'étape de ''commit'', à savoir que le ROB lui dit que les instructions précédentes sont terminées sans encombre. Là, la lecture étant invalide, le processeur déclenche une procédure de réparation, qui vide partiellement le pipeline de toutes les instructions qui suivent la lecture. La lecture est de plus ré-executée, afin de lire la bonne donnée. Avec la seconde méthode, les violations de dépendance mémoire sont détectées au moment où la lecture quitte à la file de lectures et le ROB. L'idée est de comparer la donnée lue de manière spéculative avec la donnée lue de manière non-spéculative. Pour faire la comparaison, la lecture spéculative copie la donnée lue dans la file de lecture. Quand la lecture quitte la file de lecture et le ROB, la lecture est ré-exécutée et la donnée relue. Le processeur compare alors la donnée lue à ce moment avec la donnée mémorisée dans la file de lecture. Si la donnée est différente, alors il y a eu erreur de violation de dépendance mémoire. Évidemment, relire une donnée n'est pas gratuit. Il est possible de limiter son impact en performance en dédiant un port de lecture sur le cache rien que pour ça. Mais le cout en circuit est comparable à l'usage d'une FIFO associative de la technique précédente. Mais quoi qu'il en soit, le cache doit permettre des lectures en un seul cycle, sans quoi la méthode ne marche pas vraiment. ===La prédiction de dépendances mémoires=== Certains processeurs essayent de prédire si deux accès mémoires sont dépendants : ils incorporent une unité qui va fonctionner comme une unité de prédiction de branchement, à la différence qu'elle prédit les dépendances mémoires. Si cette unité prédit que deux accès mémoires sont indépendants, le processeur les exécute dans le désordre, et les exécute dans l'ordre du programme dans le cas contraire. Il faut prendre en compte les erreurs de prédiction, ce qui est fait comme pour les mauvaises prédictions de branchement : on vide le pipeline. Beaucoup de techniques de prédiction des dépendances mémoire ont été inventées et en faire une revue exhaustive serait long et fastidieux. On pourrait citer les ensembles colorés (''color sets''), et bien d'autres techniques. Mais nous n'allons parler que des techniques les plus élémentaires. Quoi qu’il en soit, la recherche sur le sujet est assez riche, comme toujours quand il s'agit d'architecture des ordinateurs. On peut améliorer cette technique en mémorisant les instructions qui ont causé une mauvaise prédiction de dépendance mémoire. La prochaine fois qu'on exécute ces instructions, on sait qu'il y a de grandes chances qu'il se produise une erreur de prédiction, ce qui pousse à ne pas les exécuter de manière spéculative. On peut pour cela réutiliser les techniques de prédiction de branchement, tels des compteurs à saturations, mis à jour en cas d'erreurs de prédiction de dépendances mémoire. Dans tous les cas, on trouve un cache, équivalent au ''branch target buffer'', qui mémorise les instructions fautives (leur ''Program Counter''), avec d'autres informations comme les adresses des instructions dépendantes. La première classe de techniques du genre consiste à mémoriser les lectures qui ont causé une violation de dépendance, tandis que l'autre ne mémorise que les écritures. Cette dernière est l'approche utilisée par le '''cache de barrières d’écriture''' (store barrier cache). Enfin, nous allons conclure avec une dernière technique : celle des '''ensembles d’écritures''' (store sets). Cette technique est capable de gérer le cas où une lecture dépend de plusieurs écritures : le processeur mémorise l'ensemble des écritures avec lesquelles la lecture a déjà eu une dépendance. Cet ensemble d'écritures associées à une lecture est appelé tout simplement un ensemble d’écritures, et reçoit un identifiant (un nombre) par le processeur. Cette technique demande d'utiliser deux tables : * une qui assigne un identifiant d’ensemble d’écritures à l'instruction en cours ; * une autre qui mémorise les ensembles d’écritures sous la forme d'une liste chainée : le début de la liste correspond à l'écriture la plus ancienne de l’ensemble. Cela permet d'obtenir l'écriture la plus ancienne dans cet ensemble d’écritures directement. Quand le processeur détecte une violation de dépendance entre une lecture et une écriture, elle ajoute l'écriture dans l’ensemble d’écritures adéquat. Le déroulement d'une lecture demande d'accéder à la première table pour récupérer l'identifiant de l’ensemble d’écritures, et l'utiliser pour adresser la seconde table. S'il y a dépendance, cet accès renvoie l'écriture fautive en cas de dépendance. Quand une écriture est envoyée à l'unité mémoire, celle-ci va accéder à la table de correspondances de la même manière qu'une lecture, et va récupérer l'identifiant de l’ensemble d’écritures auquel elle appartient, identifiant qui est utilisé pour vérifier s'il y a une écriture en cours d’exécution. Si c'est le cas, cela signifie que l'écriture en cours d’exécution doit s’exécuter avant l'écriture qui a consulté la table : cette dernière est mise en attente. ==La prédiction d'adresse et de valeur== D'autres techniques de prédiction mémoire plus élaborées que les précédentes existent. Il s'agit de la prédiction d'adresse et de la prédiction de valeur. Leur nom trahit ce qu'elles font. La première technique tente de prédire l'adresse d'une lecture/écriture, alors que la seconde tente carrément de prédire quelle sera la donnée lue. Il y a bien de la recherche académique sur le sujet, mais il s'agit de techniques assez osées, dont on voit mal comment elles pourraient fonctionner. L'efficacité de cette technique a été étudiée grâce à des simulateurs. Suivant les études ou les programmes, on trouve des résultats qui varient pas mal. Dans certains cas, la performance baisse un peu, et dans d'autres, on peut avoir des gains de plus de 10% ! Mais dans des cas normaux, on trouve des gains de 4-5% environ. Ce qui est tout de même pas mal à l'heure actuelle. Cependant, en Janvier 2025, à l'heure où j'écris ces lignes, il a été découvert la première utilisation de ces deux techniques dans un processeur commercial. La découverte fait suite à la publication de deux vulnérabilités matérielles sur les CPU Apple M2 et M4 et quelques CPU ARM, qui font justement usage de ces deux techniques. Les deux vulnérabilités, appelées SLAP et FLOP, sont assez similaires aux attaques Spectre et Meltdown, sauf qu'elles attaquent non pas la prédiction de branchement, mais la prédiction d'adresse et de valeur du CPU. ===La prédiction d'adresse=== La prédiction d'adresse tente de prédire l'adresse d'une lecture à l'avance, ce qui permet de lancer les lectures en avance. Si la prédiction est correcte, on gagne quelques cycles qui permet aux unités de calcul de faire leurs calculs plus en avance, sans compter que les mécanismes de désambiguïsation mémoire fonctionnent plus efficacement. Si jamais cette adresse est connue plus tôt, on détecte les dépendances plus rapidement et on peut agir en conséquence. Des variantes de ces unités de prédiction d'adresse sont utilisées pour précharger des données depuis le cache de donnée. Et leur efficacité est assez bonne, voire excellente dans certains cas. La différence est que la prédiction d'adresse est utilisée pour prédire les adresses à lire depuis le cache vers les registres, ce qui est le sujet de cette sous-partie. La prédiction d'adresse est réalisée par une sorte de cache, qui associe à chaque instruction la prochaine adresse à lire. Nous appellerons ce cache la '''table de prédiction d'adresse'''. Pour faire l'association instruction <-> adresse lue/écrite, le tag du cache utilise l'adresse de l'instruction (le ''program Counter''), alors que la ligne de cache associée contient l'adresse prédite. Son contenu est utilisé pour faire une prédiction. Lorsqu'une lecture est émise, la table est consultée pour récupérer en avance l'adresse à lire, avant que celle-ci soit calculée par l'ALU. La lecture est alors exécutée en avance, avant que la vraie adresse soit connue, en utilisant l'adresse prédite. Reste qu'il faut des circuits autour de cette table de prédiction d'adresse, pour déterminer si la prédiction est correcte, ainsi que pour remplir cette table avec une adresse prédite. Pour cela, il y a un circuit de prédiction d'adresse dédié. Il récupère les instructions qui quittent le ROB ou la file des lectures mémoire, afin de vérifier les lectures qui viennent de se terminer/''commit'' et qui quittent le pipeline. Il peut contenir une '''table d'apprentissage''', qui mémorise des informations nécessaires pour faire des prédictions d'adresse. Par exemple, un historique des dernières adresses lues pour les lectures les plus récentes, ou un historique des adresses des lectures. La table est aussi utilisée pour déterminer si les prédictions effectuées étaient correctes ou non. Pour cela, il est possible de réutiliser les techniques vues dans le chapitre sur la prédiction de branchement, notamment des compteurs à saturation. Pour la prédiction de branchement et les autres formes de prédiction mémoire, les table d'apprentissage et pour les instructions/adresses sont fusionnées en une seule. Mais ici, il est nécessaire de les séparer pour une raison assez particulière : éliminer la pollution du cache dans la table de prédiction d'adresse. On ne peut pas allouer une ligne de cache pour chaque nouvelle instruction de lecture exécutée. Seule une minorité des lectures profitent de la prédiction d'adresse. Il est nécessaire de filtrer les lectures pour ne conserver que celles qui profitent de la prédiction d'adresse, ce qui est possible plus facilement en séparant la table d'apprentissage et les compteurs à saturation de la table de prédiction d'adresse. Une première méthode consiste à prédire que la lecture lit toujours la même adresse, ce qui lui vaut le nom de '''prédiction d'adresse constante'''. On pourrait se demander pourquoi une telle situation surviendrait. La raison est qu'il arrive que les compilateurs n'arrivent pas à gérer les accès mémoires efficacement. Dans certaines situations un peu compliquées impliquant des pointeurs ou des références, divers phénomènes complexes d'''aliasing'' des pointeurs peuvent générer des relectures intempestives de données en mémoire. Cela peut aussi arriver lorsqu'on compile du code qui provient de plusieurs librairies, ou quand du code lit régulièrement des constantes en mémoire ROM. La prédiction d'adresse constante ajoute un circuit qui analyse les dernières lectures, une fois qu'elles quittent le ROB, qu'elles ''commit''. Si, pour une instruction de lecture, l'adresse lue est toujours la même, alors l'instruction est ajoutée dans la table de prédiction d'adresse. Vu que les instructions qui accèdent toujours à la même adresse sont rares, il est préférable d'initialiser les compteurs à saturation de façon à ce qu'ils disent que toute nouvelle instruction change d'adresse. Il s'agit d'une méthode simple mais peu efficace, car cette situation est rare. Une autre méthode détecte les accès en enjambées, là encore quand les lectures quittent le ROB. Nous l’appellerons la '''prédiction d'adresse en enjambées'''. De tels accès surviennent régulièrement quand on accède des tableaux ou d'autres structures de données semblables, ce qui rend la technique très intéressante. Implémenter cette technique est facile : il suffit d'ajouter un circuit qui détecte les accès en enjambées, au lieu de celui qui détecte les accès constants. À chaque fois qu'une lecture entraine un succès de cache dans la table de prédiction d'adresse, l'enjambée est ajoutée à l'adresse contenue dans la table de prédiction d'adresse, l'ancienne adresse prédite est remplacée par celle avec l'enjambée d'ajoutée. Sur les Apple M2-M4, la prédiction d'adresse gére aussi bien les adresses constantes que les accès en enjambée. Le brevet qui décrit l'implémentation de l'unité de prédiction d'adresse et de valeur est potentiellement le suivant : [https://patents.google.com/patent/US11829763B2/en Early load execution via constant address and stride prediction]. En théorie, il est possible de faire mieux en repérant des accès mémoires qui se répètent de façon régulière et cyclique, même s'ils n'ont aucune enjambée. Ce genre d'accès se trouve assez souvent lorsque l'on manipule des listes chainées ou des structures de données assez irrégulières comme des arbres, des graphes, etc. Mais il n'y a pas encore de CPU qui implémente de telle optimisation. ===La prédiction de valeur : prédire la donnée lue=== Les techniques de '''prédiction de valeur''' (''Value Prediction'') consistent à prédire quelle est la donnée qui sera lue par une instruction de lecture. Oui, vous avez bien lu : le processeur est capable de parier sur la valeur qui sera chargée depuis la mémoire, de prédire si cette valeur vaut 0, 1, 1024, etc. Une fois son pari fait, il exécute les instructions du programme avec la valeur qu'il a pariée de façon spéculative. Si le pari est correct, le CPU continue l’exécution. Sinon, il est obligé de vider le pipeline et de recommencer avec la bonne valeur. Au premier abord, cette technique semble franchement mal partie tellement les chances de se tromper sont tellement énormes ! Mais le fait est que, dans certains cas, il est possible de spéculer correctement. Là encore, les techniques les plus simples fonctionnent dans deux cas. Le premier est celui où une donnée est relue à l'identique à chaque fois que la lecture est exécutée. Le second est celui où la donnée est incrémentée/décrémentée d'une constante qui est toujours la même d'une exécution de la lecture à l'autre. Une sorte d'enjambée constante, mais pour les données. Nous allons nous concentrer sur le premier cas, celui où une donnée est relue plusieurs fois sans être modifiée entre temps. L'idée de base est qu'une instruction de lecture qui s'exécute plusieurs fois de suite à la même adresse renvoie la même valeur à chaque fois. C'est parfaitement possible s’il n'y a eu aucune écriture à cette adresse entre-temps, d'où le fait qu'il faille surveiller si les prédictions constantes sont correctes. On peut se demander quelles sont les raisons qui font qu'une instruction de lecture renvoie la même valeur à chaque fois. Après tout, autant lire une seule fois la donnée et la garder dans un registre une bonne fois pour toutes ! Mais dans la réalité, on fait face à quelques limites, qui seront détaillées dans la liste suivante. La première est que le manque de registre fait que certaines données sont conservées en RAM et sont relues fréquemment. Cela arrive quand on stocke des constantes en mémoire. Par exemple, sur les processeurs x86, les constantes flottantes ne peuvent pas être intégrées dans nos instructions via le mode d'adressage immédiat. À la place, on les stocke en mémoire et on les charge dans les registres à chaque fois qu'on en a besoin. L'implémentation est similaire à la prédiction d'adresse, si ce n'est que c'est la donnée lue qui est prédite. Là encore, on a une '''table de prédiction de donnée lue''', consultée lorsqu'une lecture est émise. La table de prédiction de la donnée lue est une mémoire cache qui associe le ''Program Counter'' de la lecture à la donnée prédite, éventuellement couplé à des compteurs à saturation. Elle est consultée à chaque fois qu'une lecture est émise, la donnée adéquate est récupérée lors d'un succès de cache. On trouve aussi un circuit qui insère ou retire des instructions de la table de prédiction de donnée lue. Elle détecte les lectures prédictibles, couplé à une table d’apprentissage, et quelques circuits annexes liés au ROB ou aux files de lectures. Elle contient au minimum des compteurs à saturations, associés chacun à une instruction (son ''program counter'' pour être précis). ==L'optimisation du ''Mirroring memory operand''== Les processeurs modernes implémentent une optimisation concernant les accès mémoire. Cette optimisation a été décrite par Agner Fog dans ses manuels d'optimisation, sous le terme de '''''Mirroring memory operand'''''. Il a documenté son apparition sur les processeurs Zen 2 d'AMD, puis son évolution sur les processeurs Zen 4 et 5. L'optimisation est bizarrement absente de la microarchitecture Zen 3. Elle se manifeste dans une condition très précise : plusieurs instructions proches accèdent à la même adresse. Les deux instructions ne sont pas forcément consécutives, il peut y avoir une dizaine ou centaine d'instructions entre les deux. Une telle situation survient souvent sur les CPU CISC, et notamment sur les CPU x86, car ils ont peu de registres. les compilateurs font donc beaucoup d'échanges entre pile d'appel et registres, ce qui fait que des données écrites dans la pile sont souvent relue quelques instructions plus tard. Dans ce cas, la donnée lue/écrite est mémorisée dans un registre interne au processeur et peut être accédée très rapidement. Par exemple, imaginons plusieurs lectures proches à la même adresse, sans écriture entre elles. Dans ce cas, le processeur ne fera qu'une seule lecture en mémoire RAM, les lectures ultérieures liront la donnée depuis le registre interne. Comme autre exemple, imaginons une écriture suivie plus tard par une lecture à la même adresse. Dans ce cas, la donnée sera écrite dans un registre interne, en plus d'être propagée dans le cache ou la RAM, et la lecture ultérieure lira directement le registre interne. Le gain en performance est alors conséquent, comparé à du simple ''store-to-load forwarding''. Il faut noter que la technique marche aussi pour les opérations ''load-op'', pas seulement pour les instructions d'accès mémoire. Par exemple, prenons une écriture en RAM, suivies quelques instructions plus tard par une opération ''load-op'' à la même adresse. L'écriture écrit à une adresse, qui est lue par l'instruction ''load-op''. L'optimisation s'applique : l'instruction ''load-op'' lira l'opérande mémoire depuis un registre interne du processeur, qui contient une copie de la donnée écrite. Sur les processeurs Zen 2, l'optimisation marchait aussi pour les instructions PUSH et POP, qui lisaient/écrivaient dans la pile d'appel. Mais cette possibilité a été retirée sur les processeurs Zen 5. La technique détecte que deux instructions accèdent à la même adresse sous certaines conditions bien précises. L'optimisation ne marche que pour les modes d'adressages indicés et indirect, avec ou sans décalages, mais pas en mode absolu ou relatifs. Il faut préciser que l'optimisation ne marche que pour les registres généraux, pas les registres flottants. Les registres doivent être précisés de la même manière dans toutes les instructions. Par exemple, le processeur ne reconnait pas que l'addition des deux registres RCX + RAX est la même que RAX + RCX. Sur les CPU Zen 2 et 4, elle ne marchait que pour des opérandes de 32 et 64 bits, mais les processeurs Zen 5 gèrent des données de 8 et 16 bits aussi. Les décalages étaient limités à 8 bits sur le Zen 2, mais n'a plus de limites sur les Zen 4 et 5. ==La désambiguïsation mémoire a des effets de bord== La désambiguïsation mémoire a une caractéristique que l'exécution dans le désordre simple n'a pas : elle a des effets qui débordent sur la mémoire. Il est possible de savoir si un processeur fait de la désambiguïsation mémoire en regardant le bus mémoire. On s’aperçoit alors que les lectures se font dans un ordre différent de celui du programme. À l'opposé, l'exécution dans le désordre n'a pas d'effets observables de ce genre. Elle améliore la performance, elle modifie les durées observables des instructions, mais cela se limite à des questions de ''timings''. Le seul moyen de savoir si un processeur fait de l'exécution dans le désordre est de mesurer les latences/durées de chaque instruction. Mais aucun état observable au-delà des ''timings'' n'est modifié. Pas avec la désambiguïsation mémoire. ===La consistance mémoire=== Le fait que la désambiguïsation mémoire ait des effets observables ne pose aucun problème avec un seul processeur, ca elle est conçue pour. Par contre, les techniques de désambiguïsation mémoire peuvent poser des problèmes avec plusieurs processeurs. Il arrive que des programmes partagent une même portion de mémoire, et lisent/écrivent dedans. Avec plusieurs processeurs, chaque processeur effectue la désambiguïsation mémoire dans son coin sans se préoccuper des autres. Les opérations d'écriture peuvent être mises dans le désordre du point de vue des autres processeurs, et les lectures effectuées par les autres processeurs peuvent alors renvoyer de vielles données. Un exemple classique est celui de deux cœurs qui partagent le même cache ou la même mémoire, mais qui utilisent chacun une file d'écriture (''store buffer''). Imaginons que le premier cœur effectue une écriture, puis une lecture, à des adresses différentes. L'écriture est retardée grâce au tampon d'écriture, alors que la lecture lit directement le cache. Pour le premier processeur, l'écriture s'est réalisée avant la lecture, car pour lui, la fin de l'écriture signifie écrire dans le tampon d'écriture. Mais pour le second cœur, l'ordre s'est inversé : il voit la lecture dans le cache, puis l'écriture dans le cache. La raison est qu'il n'a pas connaissance du contenu du tampon d'écriture de l'autre cœur. Un autre exemple : les techniques de prédiction d'adresse et de valeur posent des problèmes avec les dépendances RAW. Elles permettent à une instruction mémoire de s'exécuter avant une autre, malgré la présence d'une dépendance RAW entre les deux. L'ordre entre les deux instructions change, alors que les autres processeurs ne devraient pas le voir. Et dans les faits, les processeurs qui incorporent ces techniques ont des modèles de consistance mémoire qui ne permettent pas ces optimisations en cas de dépendances RAW. Les deux techniques doivent idéalement être désactivées pour les instructions mémoire qui ont des dépendances RAW. Les programmeurs doivent tenir compte de ce genre de situations, dans des cas assez rares et liés à la programmation bas niveau. Pour cela, ils doivent savoir comment le processeur réorganise les accès mémoire. Concrètement, chaque processeur définit un '''modèle de consistance mémoire''' qui indique quelles optimisations sont activées et non. Il s'agit d'une sorte de contrat entre le logiciel et le matériel : les programmeurs savent comment le processeur va réorganiser les lectures et écritures et peuvent coder leurs programmes en fonction. L'implémentation du système de consistance mémoire dépend grandement de l'architecture, mais des modèles ont été standardisés afin de permettre aux programmeurs de savoir où placer les barrières mémoires. Il s'agit de vrais standards, formalisés, parfois mathématiquement, que des chercheurs étudient sur un plan mathématique et algorithmique. Le modèle préféré des programmeurs est la '''consistance séquentielle''', où les différents processeurs effectuent des accès mémoire dans l'ordre du programme et les accès mémoire simultanés sont interdits. La première contrainte interdit l'usage de mécanismes de désambiguïsation mémoire, la seconde interdit l'usage de caches non-bloquants. La consistance séquentielle a donc un cout en performance assez important, ce qui fait que les processeurs modernes n'utilisent pas la consistance séquentielle. Un modèle plus flexible est le '''''total store ordering''''', qui autorise la présence d'une file d'écriture et l'usage de caches non-bloquants pour les lectures (interdit d'avoir plusieurs écritures simultanées). C'est plus ou moins celui utilisé par les processeurs x86, comme on le verra plus bas. Il existe des modèles de consistance mémoire plus relâchés, appelés des '''modèles de consistance faible''', qui autorisent diverses optimisations de spéculation mémoire, la combinaison d'écriture (fusionner deux écritures à la même adresse en une seule), etc. Ils sont supportés sur les architectures ARM et POWER. ===Les barrières mémoire=== Le changement d'ordre des lectures et écritures peut poser occasionnellement des problèmes avec la programmation pour les architectures multi-processeur ou multicœurs. Il existe des morceaux de code qui ne marchent que si la consistance séquentielle est respectée et il faut faire en sorte qu'ils marchent sur des processeurs avec un modèle de consistance mémoire différent. Pour cela, les processeurs incorporent des instructions appelées '''barrières mémoires''' ou '''''Fences'''''. Elles forcent le processeur à terminer tous les accès mémoire qui précédent dans l'ordre du programme. Certains processeurs possèdent une barrière mémoire pour les lectures et une autre pour les écritures. D'autres processeurs ajoutent des barrières mémoires dédiées à des cas particuliers. Elles permettent de forcer les accès à des données partagées à dérouler correctement. [[File:Fences.png|centre|vignette|upright=2|Fences.]] Leur implémentation est assez simple : elles empêchent l'émission de toute nouvelle instruction mémoire, tant qu'une condition précise n'est pas réunie. Par exemple, la barrière mémoire pour les écritures attend que la file d'écriture soit vidée avant de démarrer le moindre accès mémoire. Un compilateur peut placer les barrières mémoires au bon endroit, avec un peu d'aide du programmeur. Certains langages de programmation permettent d'indiquer au compilateur qu'une donnée doit toujours être lue depuis la mémoire RAM, via le mot-clé volatile. C'est très utile pour préciser que cette donnée est potentiellement partagée par plusieurs processeurs ou manipulable par des périphériques. Les compilateurs peuvent placer des barrières mémoires lors des lectures ou écritures sur ces variables, mais ce n'est pas une obligation. Il arrive aussi que le programmeur doive manipuler explicitement des barrières mémoires. Utiliser l'assembleur est alors une possibilité, mais qui est rarement exploitée, pour des raisons de portabilité. Pour limiter la casse, certains systèmes d'exploitations ou compilateurs peuvent aussi fournir des barrières mémoires explicites, encapsulées dans des bibliothèques ou cachées dans certaines fonctions. ===La modèle de consistance des processeurs x86=== Après avoir vu la théorie, passons maintenant à la pratique. Dans cette partie, on va voir les modèles de consistances utilisés sur les processeurs x86, ceux qu'on retrouve dans les PC actuels. Le modèle de consistance des processeurs x86 a varié au cours de l'existence de l'architecture : un vulgaire 486DX n'a pas le même modèle de consistance qu'un Core 2 duo, par exemple. Ils ont évolués au cours du temps, avec l'arrivée de nouvelles optimisations. De plus, leur formalisation a été progressive, et s'est faite au fur et à mesure. En général, les modèles de consistance des processeurs x86 ont toujours étés assez restrictifs, si on les compare aux autres processeurs. Il s'agit de modèles de type ''total store ordering'' (TSO), au moins dans les grandes lignes. Ils permettent donc d'effectuer certaines lectures en avance, soit parce que les lectures se font à des adresses différentes, soit par utilisation de réacheminement écriture vers lecture. Les possibilités d'exécution en avance des lectures varient suivant le processeur, elles deviennent de plus en plus performantes avec le temps. Le premier modèle de consistance est apparu sur les premiers processeurs x86 et est resté en place sur tous les processeurs de marque Pentium. Les processeurs de l'époque n'incorporaient pas beaucoup d'optimisations, le budget en transistor n'était pas assez conséquent pour ça, la mémoire n'était pas assez lente. Le modèle TSO de l'époque n'autorisaient que peu de lectures anticipées. Une lecture ne peut être exécutée en avance que sous les conditions suivantes : * les écritures contournées se font dans la mémoire cache ; * la lecture doit se faire dans la mémoire RAM ; * les écritures se font à une adresse différente de la lecture ; * aucune transaction avec un périphérique ne doit être en cours. A partir du Pentium 4, les choses changent. Le Pentium 4 est en effet le premier processeur à implémenter des techniques permettant d’exécuter plusieurs processus en parallèle, dont l'Hyperthreading. En conséquence, le modèle de consistance a dû être assoupli pour éviter de perdre bêtement en performance. Il s'agit d'un vrai modèle de type TSO, la majeure partie des contraintes sur les lectures anticipées sont réduites au minimum. Il y a aussi des contraintes supplémentaires pour les instructions dites atomiques, ainsi que pour les instructions qui accèdent aux périphériques ou aux entrées-sorties. Une optimisation séparée du TSO est que les écritures en mémoire peuvent changer d'ordre dans un cas exceptionnel : les écritures effectuées par les instructions de copie mémoire comme REPMOVSD, REPSCACB, ainsi que les instructions SSE MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, MOVNTPD. Les écritures effectuées dans ces instructions peuvent se faire dans un désordre complet (ou presque). Lors du passage au 64 bits, le modèle mémoire des processeurs x86 de l'époque a été formalisé dans un papier d'Intel appelé [https://www.cs.cmu.edu/~410-f10/doc/Intel_Reordering_318147.pdf Intel® 64 Architecture Memory Ordering White Paper], mis à jour en 2008 suite à quelques problèmes techniques. La formalisation du modèle a été d'une grande aide aux programmeurs, qui devaient se débrouiller avec des documentations qui ne formalisaient pas de modèle de consistance précis. Le document décrit un modèle de consistance mémoire portant le nom barbare de ''total lock order + causal consistency” (TLO+CC)'', qui admet plus d'optimisations que le modèle TSO. Mais la description était en réalité légèrement différente du modèle mémoire réellement implémenté sur les processeurs existants, qui utilisaient du TSO, sauf en de rares occasions. Les concepteurs de processeur font souvent cela, à savoir définir un modèle de consistance plus laxiste que réellement implémenté, pour des questions de comptabilité. Ainsi, si les futurs processeurs de la marque implémentent un modèle plus laxiste, les programmes déjà codés continuent de fonctionner. Le modèle décrit dans la documentation laisse de la marge pour le futur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le parallélisme mémoire | nextText=Le parallélisme mémoire }} </noinclude> cxsebeybgbkc3xjptnwo1388vhmp66a 774138 774135 2026-10-04T14:06:09Z Mewtow 31375 /* L'exécution anticipée des calculs d'adresse */ 774138 wikitext text/x-wiki Dans les chapitres précédents, nous avons vu la mal-nommée exécution dans le désordre dans les chapitres précédents, qui devrait plutôt s'appeler l'émission dans le désordre. Elle modifie l'ordre des instructions pour gagner en efficacité, mais cela n'est valable que pour les instructions travaillant sur des registres ! Nous n'avons pas parlé de l'exécution dans le désordre des accès mémoire, car ils sont un peu à part. Et pour comprendre pourquoi, nous allons dédier ce chapitre à la gestion des accès mémoire dans le pipeline. Avant de poursuivre, faisons un rappel rapide sur les dépendances de données. Il faut distinguer deux types de dépendances de données : les dépendances de registres (deux instructions manipulent le même registre), et les dépendances d'adresse (deux instructions manipulent la même adresse). Deux instructions ont une dépendance d'adresse si elles écrivent ou lisent la même adresse mémoire. Des instructions mémoires qui lisent/écrivent des adresses différentes sont indépendantes. Lorsque deux instructions mémoire lisent/écrivent à la même adresse, on parle d''''aliasing mémoire'''. Formellement, l'aliasing mémoire n'est ni plus moins que l'application des dépendances de données aux instructions mémoire. Et comme les autres dépendances de données, il existe plusieurs types d'aliasing mémoire : RAR, RAW, WAR et WAW. Les dépendances RAR ne posent aucun problème, les lectures peuvent changer d'ordre sans aucun problème, tant qu'il n'y a pas d'écritures entre les deux. Par contre, les autres dépendances imposent trois contraintes. * Les dépendances RAW imposent que les lectures ne doivent pas s'exécuter avant une écriture à la même adresse. * Les dépendances WAR imposent que l'on ne peut pas déplacer une écriture avant une lecture à la même adresse. * Enfin, les dépendances WAW font que l'ordre des écritures doit être celui du programme : impossible de changer l'ordre de deux écritures. Les trois contraintes peuvent s'implémenter de plusieurs manières différentes. Comme pour les dépendances de données normales, seules les dépendances RAW sont de vraies dépendances de données. Les deux autres dépendances viennent du fait que l'on utilise la même adresse pour stocker des valeurs différentes. Détecter les cas d'''aliasing'' mémoire est primordial pour gérer la désambiguïsation mémoire, et cela demande de comparer des adresses. S'il existe des techniques similaires au renommage de registre permettent de les faire disparaitre, elles sont très limitées. Diverses optimisations permettent d'émettre des accès mémoire dans le désordre, exactement comme l’exécution dans le désordre le fait pour les autres instructions. Elles utilisent pour cela des ''schedulers'' spécialisées dans les accès mémoire. Il faut vraiment insister sur le fait que l'exécution dans le désordre proprement dite ne se préoccupe pas des micro-opérations mémoire. Pour faire la distinction, on parle de '''désambiguïsation mémoire''' (''memory disambiguation'') pour parler de l'exécution dans le désordre des accès mémoire. Le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. La raison est que la donnée lue est utilisée par d'autres instructions, dépendantes de la lecture. Elles sont donc à la tête d'une chaine de dépendances de données qui peut bloquer le pipeline si elle n'est pas résolue très tôt. Dès qu'une lecture peut être lancée, elle accède directement au cache de données. Par contre, si l'''aliasing'' ne le permet pas (écriture à la même adresse pas encore effectuée) ou que l'adresse à lire n'a pas encore été calculée, la lecture attend son tour. Le problème est que la désambiguïsation mémoire demande de comparer des adresses, pour détecter les dépendances d'adresse. Et cela pose de sérieuses contraintes pour son implémentation. L'exécution dans le désordre normale compare des registres, ce qui est beaucoup plus simple. Les registres utilisés sont directement encodés dans l'instruction elle-même, ce qui fait qu'on peut détecter les dépendances de registre après le décodage. Et elles peuvent même être éliminées par le renommage de registre pour les dépendances WAW et WAR. Par contre, les adresses ne sont pas encodées dans l'instruction elle-même (sauf avec le cas particulier de l'adressage absolu), mais sont lues depuis les registres ou calculées par une unité de calcul. Les adresses ne sont donc connues qu'une fois que l'instruction a attendu suffisamment de temps dans le ''scheduler''. ==L’émission dans l'ordre des accès mémoire== Avant toute chose, faisons quelques rappels sur l'exécution dans le désordre "normale", sans désambiguïsation mémoire. Les micro-opérations renommées sont mises en attente dans un ''scheduler'', le temps que leurs opérandes soient disponibles. Les µops mémoire font cependant exception. ===La file de µops mémoire=== Avec l''''émission dans l'ordre des accès mémoire''', le processeur émet les accès mémoire dans l'ordre du programme. Cela implique que l'unité mémoire n'a pas de ''scheduler'', ni de station de réservation associée. À la place, les micro-opérations mémoire sont placées dans une file de micro-opération dédiée, appelée la '''file de µops mémoire'''. La file de µops mémoire est une mémoire FIFO, dont chaque entrée peut accueillir une µops mémoire. [[File:Unité de gestion des accès mémoires dans l’ordre.png|centre|vignette|upright=2.0|Unité de gestion des accès mémoires dans l’ordre]] Par contre, le processeur utilise des ''schedulers'' pour les unités de calcul. Autant les accès mémoire se font dans l'ordre, autant les instructions arithmétiques/logiques/branchements/autres sont exécutées dans le désordre. En clair, le processeur supporte l'exécution dans le désordre, sauf pour les micro-opérations mémoire. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] Vu de l'extérieur, il faut attendre qu'un accès mémoire soit terminé pour lancer le suivant. Avant d'émettre une micro-opération mémoire, la file de micro-opération (son ''scoreboard'') vérifie si l'unité d'accès mémoire est libre ou non. Si elle est occupée, un accès mémoire est en cours, et on ne peut pas émettre de micro-opération mémoire. Une fois l'accès mémoire terminé, la mémoire ou le cache envoient un signal pour indiquer que l'accès est fini, et c'est au tour d'une nouvelle micro-opération mémoire. L'unité mémoire a un port sur lequel on envoie la micro-opération émise, que ce soit une écriture ou une lecture. Une file de µops implique la ''lecture après émission'', à savoir que les µops mémoire quittent la file de µops, avant de lire les registres adéquats. Il y a donc une connexion entre banc de registre et unité mémoire, comme sur n'importe quel autre processeur. Mais la file de µops n'est pas connectée au banc de registre, ni au réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] L'organisation précédente laisse penser que les calculs d'adresse sont forcément réalisés dans l'unité mémoire. Mais ce n'est pas une obligation. Il est parfaitement possible de scinder une µops mémoire en deux : une µop mémoire proprement dite et une µop de calcul d'adresse. La liaison entre les deux doit cependant se faire en utilisant un registre. L'adresse calculée est enregistrée dans un registre d'adresse "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. ===La file d'écriture : une FIFO de mise en attente des écritures=== L'unité mémoire exécute une µops mémoire à la fois. Avec un détail particulier : les écritures doivent attendre que toutes les instructions se terminent, avant de pouvoir s'exécuter. Sans ça, les exceptions précises sont impossibles, la prédiction de branchement l'est aussi et l'exécution dans le désordre aurait des conséquences fâcheuses (en exécutant des écritures en avance). Précisément, les écritures doivent respecter deux contraintes : s'exécuter dans le tout dernier étage du pipeline, s'exécuter dans l'ordre du programme. Il serait possible de bloquer l'émission des écritures tant que le ROB ne donne pas son accord. Mais ce n'est pas du tout ce qui est fait. A la place, les écritures sont mises en attente dans une mémoire FIFO, appelée la '''file d'écriture''', ou encore la ''Write Queue''. Les écritures sont mises en attente, jusqu'à ce que le ROB leur donne le okay pour transfer l'écriture en mémoire RAM. Nous en avions déjà parlé dans le chapitre sur le pipeline, dans la section sur les exceptions précises. Mais quelques rappels ne feront pas de mal. L'écriture est insérée dans la file d'écriture lorsqu'elle est émise, elle en sort à l'étage de ''Writeback'' si tout se passe bien. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente, faites à tort. De plus, la file d'écriture est une mémoire FIFO, qui mémorise les écritures dans l'ordre du programme. En effet, les écritures sont ajoutées dans la file d'écriture lorsqu'elles sont émises, et elles sont émises dans l'ordre du programme. [[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Write Queue sur un pipeline fixe]] La file d'écriture garantit que les écritures sont dans l'ordre du programme. Et cette propriété élimine totalement les dépendances WAW. Le fait que les écritures se font à la fin du pipeline élimine quant à elle les dépendances WAR. Il ne reste plus qu'à gérer les dépendances RAW, celles où une lecture lit une donnée écrite par une autre instruction. Une telle situation survient, car les écritures sont mises en attente pendant un certain temps, la donnée écrite peut être lue pendant ce laps de temps. La lecture ne peut pas lire la donnée dans le cache, car la donnée à lire n'y est pas encore enregistrée. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire, mais il existe. Il se manifeste surtout sur les processeurs avec peu de registre, où des données doivent régulièrement être déplacées temporairement en mémoire RAM. Pour éviter cela, une solution consiste mettre en attente les lectures si une écriture est en attente. Les lectures sont maintenues dans la file de µops tant que la file d'écriture n'est pas vide. La file de µops mémoire est suivie d'un circuit de détection des dépendances, un '''''scoreboard'' mémoire''' qui est assez simple. L'unité mémoire fournit un bit qui indique si la file d'écriture est vide ou non, ainsi qu'un second bit qui indique qu'elle est vide. Les deux ne sont pas compliqués à générer, ils font partie intégrante de l'interface de toute mémoire FIFO digne de ce nom. Le ''scoreboard'' mémoire utilise ces deux bits pour savoir s'il peut émettre une micro-opération mémoire. Il n'émet pas de lecture tant que la file d'écriture n'est pas vide. Il peut par contre émettre des écritures tant qu'elle n'est pas pleine. Une meilleure solution serait d'interdire les lectures, mais seulement en cas de dépendance mémoire détectée. Et c'est là qu'intervient la technique du '''''load bypassing'''''. Elle autorise une lecture, si elle n'a aucune dépendance avec les écritures mises en attente dans la file d'écriture. Elle doit être mise en attente dans le cas contraire. Pour l'implémenter, il faut comparer l'adresse à lire avec toutes les adresses dans la file d'écriture. La lecture est exécutée immédiatement si aucune correspondance n'est trouvée, mais elle est mise en attente dans le cas contraire. Pour cela, la file d'écriture est modifiée et devient un mélange entre FIFO et mémoire associative. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'' : la lecture doit être bloquée. Sinon, c'est un défaut de ''Write Queue'', la lecture accède au cache, là où se trouve la donnée à lire. Dans le chapitre sur le contournement, nous avons parlé d'une forme de contournement qui corrige ce problème. Les lectures peuvent lire la donnée depuis la file d'écriture si besoin, ce qui renvoie la donnée adéquate. Pour cela, les lectures consultent à la fois le cache et la file d'écriture, comme dit plus haut. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'', ou encore de '''réacheminement écriture-vers-lecture'''. [[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]] La lecture est mise en attente dans l'unité mémoire, vu que c'est elle qui fait les comparaisons d'adresse. Du point de vue extérieur, le ''scoreboard'' mémoire ne voit pas s’il y a eu succès dans la file d'écriture ou non. Il voit juste que l'unité mémoire est occupée et ne peut pas accepter de nouvelle lecture. Elle reste occupée durant quelques cycles, en cas de défaut de ''Write Queue'', le temps de faire la lecture dans le cache. Par contre, en cas de succès de ''Write Queue'', elle reste occupée tant que la lecture n'a pas donné de résultat, ce qui inclue le temps de mise en attente. Les processeurs haute performance implémentent tous le ''Store to load Forwarding'', mais quelques processeurs basse performance ne le font pas. Ils se contentent du ''load bypassing'', qui offre des performances correctes pour un cout en matériel bien plus modeste. Pour donner un exemple, la micro-architecture Jaguar d'AMD était dans ce cas. Elle a été utilisée sur les consoles de jeu de 8ème génération, comme la Xbox One et la Playstation 4. Les processeurs haute performance préférent utiliser plus de circuits pour implémenter le ''Store to load Forwarding'', mais l'implémentation n'est souvent pas complète. En effet, l'implémentation est souvent simple et ne prend en compte que des lectures/écritures à des adresses identiques, sans compter que la taille des données doit aussi être identique. Une écriture d'un entier de 32 bit sera contournée et envoyée à une lecture de 32 bits à la même adresse. Mais la lecture lit seulement 16 bits sur les 32 écrits, le contournement peut ne pas marcher. Concrètement, ce cas précis fonctionnera. Mais le cas inverse, avec une lecture lisant plusieurs écritures de taille plus petite, ne marchera pas sur tous les processeurs. Ne parlons pas du cas où les données écrites sont à cheval sur deux lignes de cache. ==L'exécution anticipée des calculs d'adresse== L'exécution dans le désordre fonctionne mieux si les lectures sont exécutées le plus tôt possible. Les lectures lisent des données qui sont utilisées par d'autres instructions. Si la lecture tarde, les autres instructions sont mises en attente. Exécuter les lectures le plus tôt possible est donc crucial. Mais comment faire ? Un moyen pour ça est de faire en sorte que les adresses à lire/écrire soient connues le plus tôt possible. Mais cela implique que les calculs d'adresse ne sont pas fait en même temps que l'accès mémoire. Pour cela, il est possible de séparer les µops mémoire en deux micro-instructions : une pour le calcul d'adresse et l'autre pour les accès mémoire. On parle de '''calcul anticipé des adresses'''. Avant de poursuivre, un minimum de terminologie. Nous parlerons d'AGU pour désigner les unités de calcul d'adresse, ce qui est une abréviation de ''Adresse Generation Unit''. Nous parlerons aussi de '''µops AGU''' pour parler des µops de calcul d'adresse. Le terme est de moi, il est assez parlant : "µops exécutés dans les AGUs". Le calcul anticipé des adresses est utilisé avec ou sans désambiguïsation mémoire. Elle a des avantages avec et sans. Sans, elle calcule les adresses en avance, ce qui fait que des lectures/écritures mises en attente ont leurs opérandes prêtes plus rapidement. Avec, cela permet de connaitre les adresses en avance, ce qui permet de détecter plus rapidement les dépendances d'adresse. Mais laissons cela pour plus tard. ===La ''Load/Store Queue'' : une file de µops améliorée=== Implémenter cette technique demande de faire deux choses. Premièrement, il faut faire les calculs d'adresse en-dehors de l'unité mémoire, soit dans une ALU entière, soit dans une unité de calcul d'adresse dédiée. Deuxièmement, les adresses calculées doivent être mises en attente quelque part, et être liées facilement à la µop mémoire associée. Pour le second point, la file de µops mémoire parait être un bon candidat, mais n'est pas exactement la solution. Une file de µops au sens strict ne mémorise pas les opérandes des µops, donc pas d'adresse. Cela implique la lecture après émission, ce qui veut dire que l'adresse à lire/écrire est lue depuis les registres une fois que la µops sort de la file de µops (idem pour la donnée à écrire). En pratique, cette implémentation rend difficile de faire les calculs d'adresse ailleurs que dans l'unité mémoire. Heureusement, il est possible d'adapter la file de µops pour gérer la lecture avant émission. L'idée est qu'elle mémorise, pour chaque µop mémoire, l'adresse à lire/écrire et d'autres informations. Il ne s'agit donc plus vraiment d'une file de µops mémoire mais quelque chose d'autre, une sorte d'intermédiaire entre une station de réservation et une file de µops. D'un côté, elle fonctionne comme une file de µops car elle émet les µops dans l'ordre du programme. De l'autre, elle mémorise les opérandes des µops mémoire, ce qui en fait une pseudo-station de réservation. Une telle structure est appelée une '''''Load/Store Queue''''' (LSQ). Une LSQ contient plusieurs entrées, chaque entrée pouvant accueillir une µops mémoire et ses opérandes. Une entrée contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures. Les deux derniers champs sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. Elle contient aussi des bits de validité, qui indique si l'adresse ou la donnée sont disponibles, ou si le champ associé est vide. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2|Load Store Queue unifiée]] La LSQ doit faire le lien entre une adresse calculée par l'ALU entière, et l'entrée associée de la LSQ. Pour cela, l'unité d'émission peut associer un '''identifiant LSQ''' à une µops mémoire. L'identifiant est généré à l'émission. Aussi bien la µops de calcul d'adresse que a µop mémoire ont toutes deux le même identifiant. L'identifiant est propagé avec la µop de calcul d'adresse, comme tout signal de commande. En sortant de l'AGU, l’identifiant est transmis à la LSQ en même temps que l'adresse. La LSQ regarde quelle entrée a cet identifiant, et mémorise l'adresse dedans. La gestion des données à écrire se fait elle avec le système de contournement normal. La LSQ sait dans quel registre se trouve la donnée à écrire, elle surveille juste le système de contournement pour détecter quand la donnée est disponible. ===Le contournement des adresses mémoire avec une LSQ=== Avec une LSQ, les registres sont lus quand la µop mémoire est insérée dans la LSQ, si elles sont disponibles. Si elles ne sont pas encore disponibles, elles seront envoyées par le système de contournement plus tard. Cela implique qu'il y ait un système de contournement, qui relie les ALUs entières à la LSQ. Les AGUs envoient leur résultat à la LSQ, qui place alors l'adresse calculée dans la bonne entrée, la bonne "µop". [[File:Load-Store Queue.png|centre|vignette|upright=2|Load-Store Queue]] Une autre solution utilise des ALU spécialisées dans les calculs d'adresse, appelées des ''Adress Generation Unit'', abrévié AGU. L'avantage est que cela simplifie grandement le réseau de contournement : seules les AGU sont connectées à la LSQ. Un autre avantage est lié à l'adressage de la destination. En utilisant des opérations entières usuelles, il faudrait préciser que le résultat est à destination de la LSQ, pour configurer le réseau de contournement. Avec des AGU, les calculs d'adresse sont des micro-opérations différentes des additions et soustraction, elles sont envoyées au AGU spécifiquement, pas besoin de préciser la destination. [[File:ALU et LSQ.png|centre|vignette|upright=2|AGUs et Load-Store Queue.]] Dans les deux cas, les µops de calcul d'adresse sont alors considérées comme des opérations entières un peu spéciales, gérées par le système d'exécution dans le désordre. Elles peuvent être émises dans le désordre, mais la LSQ se chargera de faire les accès mémoire dans l'ordre. Il y a donc calcul des adresses dans le désordre, mais accès mémoire dans l'ordre. ==Les ''schedulers'' mémoire unifiés== Avec la technique précédente, les µops mémoire s'exécutent dans l'ordre, à savoir que l'on ne peut pas déplacer une lecture avant ou après une autre. Il arrive que des lectures prêtes soient mises en attente, parce qu'une lecture précédente attend ses opérandes. Et ce retard sur les lectures se répercute sur les instructions lecture-dépendantes, donc sur le reste du pipeline.Les conséquences sur les performances sont assez importantes. Mais avec l'implémentation qui va suivre, il est possible exécuter les lectures dans le désordre tant que les conditions d'''aliasing'' sont respectées. L'implémentation remplace les files de µops mémoire par un ''scheduler'' spécialisé dans les µops mémoire. Il sera appelé le '''''scheduler'' mémoire''' dans ce qui suit. [[File:Processeur avec émission dans le désordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans le désordre des accès mémoire]] Une entrée du ''scheduler'' mémoire contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures (inutile pour les écritures). Elle contient aussi des bits de validité pour chaque champ qui indiquent si l'adresse d'accès est connue, ou si la donnée à écrire est disponible pour une écriture. Les champs pour la donnée à écrire ou le registre de destination sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2.5|''Load/Store Queue''.]] Lorsqu'une micro-opération mémoire est émise, elle est insérée dans le ''scheduler'' mémoire. Si l'adresse à lire/écrire est disponible, elle est immédiatement lue depuis les registres, idem pour une éventuelle donnée à écrire. Sinon, la micro-opération attend dans le ''scheduler'' mémoire que son adresse soit calculée par le processeur. ===Les calculs d'adresse avec un ''scheduler'' mémoire=== Le ''scheduler'' mémoire détecte des dépendances d'adresse entre instructions. Pour cela, il compare toutes les adresses à lire/écrire. Il mémorise donc les dites adresses, ce qui en fait une station de réservation. Et cela implique que ces adresses ont été calculées ailleurs, dans une ALU entière, ou dans une AGU séparée de l'unité mémoire. L'implémentation la plus simple effectue les calculs d'adresse dans les ALU entières. La sortie de toutes les ALU entière est reliée au ''scheduler'' mémoire. [[File:Contournement pour la file de µops mémoire.png|centre|vignette|upright=2|''scheduler'' mémoire]] Une autre implémentation utilise des unités de calcul d'adresse dédiées, afin de simplifier le réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse via AGU.png|centre|vignette|upright=2|ALU et ''scheduler'' mémoire]] Une conséquence est que l'implémentation impose que toutes les instructions mémoire sont découpées en deux µops : une µop pour calculer l'adresse, et une µop mémoire. C'est un défaut assez important, car une instruction mémoire prendra deux places dans le ROB, dans les stations de réservation, etc. Vu que les instructions mémoire sont très fréquentes, la capacité du ROB/autres est alors fortement réduite. Il faut noter que les adresses calculées dans l'ALU/AGU doivent atterrir dans l'entrée adéquate, celle de la µops qui doit recevoir l'adresse. La seule solution est que le ''program counter'' de la µops est transmis dans le pipeline, propagé avec la µops de calcul d'adresse. Quand le résultat est calculé, le ''program counter'' associé est envoyé avec au ''scheduler'' mémoire. Le ''scheduler'' mémoire enregistre alors l'adresse dans l'entrée qui est taguée avec cette adresse. ===La détection des dépendances mémoire=== Le ''scheduler'' mémoire intègre un système de détection des dépendances, qui est bien différent de celui d'une station de réservation normale. Les ''schedulers'' mémoire comparent non pas des noms de registres, mais des adresses/opérandes. Pour détecter les dépendances, le ''scheduler'' mémoire compare les adresses à lire/écrire. Pour les µops dont l'adresse est inconnue, le cas dépend de l'implémentation. La version la plus conservatrice suppose qu'elle a des dépendances possibles avec toutes les autres. Chaque micro-opération en attente compare son adresse avec celle de toutes les micro-opérations précédentes. Ou presque, la comparaison ne doit tenir compte que des écritures, et de celles situées avant dans l'ordre du programme. Le ''scheduler'' mémoire doit donc mémoriser les instructions dans l'ordre du programme, tout en faisant des comparaisons d'adresse. Ça en fait une sorte hybride entre mémoire associative et mémoire FIFO. Nous avions vu dans le chapitre "L'exécution dans le désordre" qu'il y a plusieurs manières pour implémenter une station de réservation : avec une mémoire associative, avec préplanification, avec des matrices de dépendances. Le problème est qu'on ne peut pas utiliser une mémoire associative pour le ''scheduler'' mémoire, qui utilise des comparateurs pour comparer les noms de registres/adresses. En effet, la détection des dépendances mémoire demande de faire beaucoup de comparaisons : chaque adresse est comparé à toutes les autres. Et les adresses faisant facilement 32 à 64 bits, les comparateurs sont bien plus imposants que ceux qui comparent des noms de registres de 5-10 bits. Le cout en matériel serait trop élevé, de même que le temps de calcul des dépendances. À la place, on utilise des matrices de dépendances. La détection des dépendances mémoire utilise précisément deux matrices de ce type : une matrice de disponibilité, et une matrice de dépendance. Ces matrices forment une espèce de tableau carré, organisé en lignes et en colonnes. L'instruction dans la énième entrée se voit attribuer la énième ligne et la énième colonne. À l'intersection d'une ligne et d'une colonne, on trouve un bit qui indique si l'instruction de la ligne et celle de la colonne ont une dépendance. La '''matrice de disponibilité''' permet de bloquer l'exécution des lectures/écritures tant qu'une instruction précédente n'a pas calculé son adresse, tant qu'on ne peut pas vérifier les dépendances. Lorsqu'une instruction est ajoutée dans le ''scheduler'' mémoire, elle met tous les bits de la colonne attribuée à 1 si l'adresse à lire/écrire est inconnue, puis les met à 0 une fois l'adresse calculée. Une instruction ne peut pas démarrer si, sur la ligne qui lui est attribuée, se trouve un 1. Plus précisément, si on trouve un 1 pour les instructions plus anciennes, ce qui fait qu'une partie de la matrice est ignorée. Les bits notés X dans le tableau suivant sont ignorés, car ils précisent la disponibilité des opérandes d'un opérande ultérieure, ce qui est inutile. En clair, les calculs d'adresse modifient les colonnes, et on vérifie les lignes pour autoriser l'exécution. {|class="wikitable" |- ! Instructions mémoire dans la LSB, triées de la plus ancienne à la plus jeune ! 0 !! 1 !! 2 !! 3 !! 4 !! 5 !! 6 |- ! 0 | 1 || X || X || X || X || X || X |- ! 1 | 0 || 0 || X || X || X || X || X |- ! 2 | 0 || 1 || 0 || X || X || X || X |- ! 3 | 0 || 1 || 1 || 1 || X || X || X |- ! 4 | 0 || 0 || 0 || 0 || 1 || X || X |- ! 5 | 0 || 0 || 1 || 0 || 0 || 1 || X |- ! 6 | 1 || 1 || 1 || 1 || 1 || 1 || 0 |} Les '''matrices de dépendances''' fonctionnent sur le même principe, mais déterminent les dépendances mémoire. Lorsque le processeur a calculé une adresse, il la compare avec les adresses à lire/écrire déjà dans le ''scheduler'' mémoire. Il met alors à jour les bits de la colonne de la matrice de dépendance : le bit à l'intersection d'une ligne et d'une colonne est mis à 0 si les deux instructions n'ont pas de dépendances, à 1 si elles en ont une. Une instruction mémoire est exécutée si tous les bits de sa ligne sont à 0. Là encore, on ne tient compte que des bits pour les instructions plus anciennes, la matrice a encore une fois une forme triangulaire, comme pour la matrice de disponibilité. Une optimisation scinde le ''scheduler'' mémoire en deux structures, aux rôles légèrement différents. La première gère la disponibilité des opérandes, la seconde gère seulement les dépendances mémoire. La première est une station de réservation où les instructions sont mises en attente tant que leurs opérandes ne sont pas prêts. La seconde met en attente des micro-opérations dont les opérandes sont prêts, mais qui doivent attendre pour une autre raison : dépendance mémoire, cache occupé, etc. L'avantage est qu'on remplace une grosse station de réservation par deux plus petites, mais l'intérêt est surtout relié au système de détection des dépendances. Sans cette optimisation, on a deux matrices énormes reliées à une ''Load/Store Queue'' unique. Avec la séparation, la matrice de disponibilité est reliée uniquement à la première station de réservation, alors que la matrice de dépendance est reliée uniquement à l'autre. Les deux matrices sont donc plus petites, car reliées à deux stations de réservation mémoire plus petites. ==L'exécution spéculative des accès mémoires== Pour rappel, le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. Les données lues sont en effet, le départ d'une série d'instructions dépendantes, généralement des instructions arithmétiques, mais parfois d'autres lectures. Quand on manipule des structures de données, il n'est pas rare qu'une lecture lise un pointeur-adresse utilisé par une autre lecture. Les techniques de désambiguïsation mémoire que nous avons vu précédemment sont dites conservatrices, à savoir qu'elles ne changent pas l'ordre de deux instructions mémoire si elles ont une dépendance d'adresse. Elles exécutent une lecture le plus tôt possible, mais seulement si on sait que celle-ci n'aliase pas d'écritures précédentes. Et cela demande de connaitre les adresses des écritures précédentes dans l'ordre du programme. Si une lecture est précédée par une ou plusieurs écritures, les adresses à écrire doivent être connues. Il se peut en effet qu’une écriture dont on ne connait pas l'adresse aliase la lecture. La garantie est liée à la présence de la file de µops mémoire ou la file de µops STORE. Si une seule écriture a une adresse inconnue, elle reste dans la file de µops mémoire et bloque l'émission de toutes les autres micro-opérations mémoire, lecture comme écriture. Mais il existe des techniques dites spéculatives, qui modifient l'ordre des accès mémoire sans se soucier de leurs dépendances. Elles émettent les lectures dès que leurs opérandes sont prêts, sans se préoccuper de l'aliasing avec une écriture précédente. Pour éviter tout problème, le processeur vérifie si aucune dépendance mémoire n'est violée et corrige le tir dès qu'une violation est détectée. Le processeur vérifie s'il a fait une erreur de prédiction, en vérifiant les adresses à écrire ou lire. En cas d'erreur, le processeur fait comme lors d'une prédiction de branchement ratée : il remet le pipeline en ordre et ré-exécute les accès mémoire dans le bon ordre. Il s'agit d'un cas particulier d’'''exécution spéculative''', au même titre que la prédiction de branchement. Toutes les techniques de ce genre exécutent des lectures dans le désordre. L'avantage est que cela permet d'exécuter les lectures en avance. Vu que la donnée lue est l'opérande d'autres instructions, les exécuter en avance fait prendre de l'avance à un paquet d'instructions. Mais il faut comparer cet avantage au risque de mauvaise prédiction. Et aussi bizarre que cela puisse paraître, cela donne de bons résultats. Il faut dire que les situations où une lecture relit une donnée tout juste écrite sont rares : même pas 2 à 3 % des lectures. Les dépendances mémoire sont donc très rares, les cas d'''aliasing'' sont vraiment limités. En termes de cout en transistors, le hardware qui vérifie les dépendances mémoire reste globalement le même à peu de choses près, il est juste déplacé dans le pipeline et adapté à sa nouvelle fonction. ===La file de lectures=== La technique que nous allons voir émet les lectures dans l'ordre, ce qui simplifie énormément la gestion des dépendances. Les lectures sont émises dans l'ordre, les écritures aussi. Par contre, l'ordre entre lectures et écritures n'est pas respecté. Une lecture peut passer avant une écriture sans problèmes, voire avant plusieurs écritures d'un seul coup. Une telle lecture exécutée en avance d'une autre écriture est appelée une '''lecture spéculative'''. Une lecture spéculative peut donc sauter devant une ou plusieurs écritures, qui seront appelées des '''écritures sautées'''. Une écriture sautée est une précédant une lecture spéculative, dans l'ordre du programme. Évidemment, il se peut qu'une écriture sautée écrive la donnée qui est lue par la lecture spéculative. Il y a alors une violation de dépendance mémoire. Mais le processeur suppose qu'il n'y a pas de dépendantes de ce type et émet la lecture en avance. Rassurez-vous, il surveille quand même la situation, pour tout annuler ensuite s'il détecte une violation de dépendance mémoire. J'ai dit plus haut que le processeur émet les lectures dans l'ordre, ainsi que les écritures dans l'ordre. Pour cela, la LSQ est scindée en deux files de µops : une pour les µops LOAD et une autre pour les µops STORE. Les cours d'architecture des ordinateurs nomment souvent ces deux files en utilisant les termes de ''file de STORE'' (''store queue'') et de ''file de LOAD'' (''load queue''), mais il y a un risque de confusion avec la file d'écriture vue précédemment, et la file de lecture qu'on introduira dans la suite. Dans ce qui suit, nous parlerons de '''file de µops LOAD''' et de '''file de µops STORE''', pour éviter toute confusion. Les deux files sont chacune connectées à l'unité mémoire, via un port dédié. L'unité mémoire a donc un port pour les lectures, et un port pour les écritures. Le port d'écriture est relié à la file d'écriture, le port de lecture est directement relié au cache de données. Il faut noter qu'une unité mémoire à deux ports n'est pas si bizarre que cela. En effet, le cache de données a lui aussi deux ports, dans la majorité des cas, avec un port de lecture et un port d'écriture. [[File:Load Ordering, Store Ordering.jpg|centre|vignette|upright=2|Exécution dans le désordre des lectures.]] Maintenant, voyons comment sont détectées les violations de dépendances mémoire. Les dépendances qui peuvent être violées dans ce cas de figure se font entre une écriture sautée et une lecture spéculative. Si une lecture spéculative lit une donnée qui est écrite par une écriture sautée, alors une dépendance RAW a été violée. Pour les détecter, le processeur garde une trace des lectures spéculatives; tant que les instructions précédentes ne sont pas terminées. Pour cela, il utilise une file d'attente, qui mémorise les lecture spéculatives. Nous l'appellerons la ''file de vérification des lectures'', ou encore la '''file de lectures'''. Elle est une sorte d'équivalent de la file d'écriture, mais pour les lectures. Les lectures sortent de la file µops LOAD dès que leurs adresses sont calculées, sans tenir compte des dépendances mémoires. Elles sont alors ajoutées à la file de lectures et s'exécutent immédiatement, de manière spéculative. Pour vérifier qu'il n'y a pas d'erreur de spéculation, le processeur conserve l'adresse de la lecture effectuée dans la file de lectures. Les lectures quittent la file de lecture quand toutes les instructions précédentes dans l'ordre du programme sont terminées, à savoir tant quand elles ont quitté le tampon de réordonnancement/ROB. Notons que la file de vérification des lectures contient les lectures dans l'ordre du programme. En effet, les lectures sont accumulées en sortant de la file de µops LOAD, où elles sont triées dans l'ordre du programme. Et cela permet de gérer les erreurs de prédiction parfaitement, en annulant les instructions suivant une erreur de prédiction. [[File:Spéculation avec une file de lecture.jpg|centre|vignette|upright=2.5|Spéculation avec une file de lecture.]] Reste à détecter les erreurs de prédiction, ce qui peut être fait avec deux méthodes. La première détecte les violations de dépendances mémoire à chaque écriture. L'idée est que chaque écriture vérifie si une lecture anticipée n'a pas lue la donnée écrite en avance. Pour cela, quand une écriture est émise, elle consulte la file de lecture pour voir s'il y a un conflit d'adresse. La vérification fait cependant gaffe à ne prendre en compte que les lectures situées après l'écriture dans l'ordre du programme. Précisément, elle envoie l'adresse à écrire à la file de lecture et cherche si une lecture ultérieure a eu lieu à la même adresse. Si c'est le cas, cela signifie qu'une lecture anticipée aurait du lire la donnée actuellement écrite, mais que la lecture a été exécutée trop en avance. La lecture n'a pas lu la bonne donnée, il y a eu une violation de dépendance mémoire. Pour faire cette vérification, la file de lecture est un mélange entre FIFO et cache/mémoire associative, sur le même modèle que la file d'écriture. La différence est que la consultation de la file de lecture ne sert pas à faire du contournement, mais à détecter les dépendances. Quand une dépendance est détectée, l'écriture se fait normalement. Par contre, la lecture marquée comme invalide dans la file de lecture. L'exécution du programme se poursuit, jusqu'à ce que la lecture arrive à l'étape de ''commit'', à savoir que le ROB lui dit que les instructions précédentes sont terminées sans encombre. Là, la lecture étant invalide, le processeur déclenche une procédure de réparation, qui vide partiellement le pipeline de toutes les instructions qui suivent la lecture. La lecture est de plus ré-executée, afin de lire la bonne donnée. Avec la seconde méthode, les violations de dépendance mémoire sont détectées au moment où la lecture quitte à la file de lectures et le ROB. L'idée est de comparer la donnée lue de manière spéculative avec la donnée lue de manière non-spéculative. Pour faire la comparaison, la lecture spéculative copie la donnée lue dans la file de lecture. Quand la lecture quitte la file de lecture et le ROB, la lecture est ré-exécutée et la donnée relue. Le processeur compare alors la donnée lue à ce moment avec la donnée mémorisée dans la file de lecture. Si la donnée est différente, alors il y a eu erreur de violation de dépendance mémoire. Évidemment, relire une donnée n'est pas gratuit. Il est possible de limiter son impact en performance en dédiant un port de lecture sur le cache rien que pour ça. Mais le cout en circuit est comparable à l'usage d'une FIFO associative de la technique précédente. Mais quoi qu'il en soit, le cache doit permettre des lectures en un seul cycle, sans quoi la méthode ne marche pas vraiment. ===La prédiction de dépendances mémoires=== Certains processeurs essayent de prédire si deux accès mémoires sont dépendants : ils incorporent une unité qui va fonctionner comme une unité de prédiction de branchement, à la différence qu'elle prédit les dépendances mémoires. Si cette unité prédit que deux accès mémoires sont indépendants, le processeur les exécute dans le désordre, et les exécute dans l'ordre du programme dans le cas contraire. Il faut prendre en compte les erreurs de prédiction, ce qui est fait comme pour les mauvaises prédictions de branchement : on vide le pipeline. Beaucoup de techniques de prédiction des dépendances mémoire ont été inventées et en faire une revue exhaustive serait long et fastidieux. On pourrait citer les ensembles colorés (''color sets''), et bien d'autres techniques. Mais nous n'allons parler que des techniques les plus élémentaires. Quoi qu’il en soit, la recherche sur le sujet est assez riche, comme toujours quand il s'agit d'architecture des ordinateurs. On peut améliorer cette technique en mémorisant les instructions qui ont causé une mauvaise prédiction de dépendance mémoire. La prochaine fois qu'on exécute ces instructions, on sait qu'il y a de grandes chances qu'il se produise une erreur de prédiction, ce qui pousse à ne pas les exécuter de manière spéculative. On peut pour cela réutiliser les techniques de prédiction de branchement, tels des compteurs à saturations, mis à jour en cas d'erreurs de prédiction de dépendances mémoire. Dans tous les cas, on trouve un cache, équivalent au ''branch target buffer'', qui mémorise les instructions fautives (leur ''Program Counter''), avec d'autres informations comme les adresses des instructions dépendantes. La première classe de techniques du genre consiste à mémoriser les lectures qui ont causé une violation de dépendance, tandis que l'autre ne mémorise que les écritures. Cette dernière est l'approche utilisée par le '''cache de barrières d’écriture''' (store barrier cache). Enfin, nous allons conclure avec une dernière technique : celle des '''ensembles d’écritures''' (store sets). Cette technique est capable de gérer le cas où une lecture dépend de plusieurs écritures : le processeur mémorise l'ensemble des écritures avec lesquelles la lecture a déjà eu une dépendance. Cet ensemble d'écritures associées à une lecture est appelé tout simplement un ensemble d’écritures, et reçoit un identifiant (un nombre) par le processeur. Cette technique demande d'utiliser deux tables : * une qui assigne un identifiant d’ensemble d’écritures à l'instruction en cours ; * une autre qui mémorise les ensembles d’écritures sous la forme d'une liste chainée : le début de la liste correspond à l'écriture la plus ancienne de l’ensemble. Cela permet d'obtenir l'écriture la plus ancienne dans cet ensemble d’écritures directement. Quand le processeur détecte une violation de dépendance entre une lecture et une écriture, elle ajoute l'écriture dans l’ensemble d’écritures adéquat. Le déroulement d'une lecture demande d'accéder à la première table pour récupérer l'identifiant de l’ensemble d’écritures, et l'utiliser pour adresser la seconde table. S'il y a dépendance, cet accès renvoie l'écriture fautive en cas de dépendance. Quand une écriture est envoyée à l'unité mémoire, celle-ci va accéder à la table de correspondances de la même manière qu'une lecture, et va récupérer l'identifiant de l’ensemble d’écritures auquel elle appartient, identifiant qui est utilisé pour vérifier s'il y a une écriture en cours d’exécution. Si c'est le cas, cela signifie que l'écriture en cours d’exécution doit s’exécuter avant l'écriture qui a consulté la table : cette dernière est mise en attente. ==La prédiction d'adresse et de valeur== D'autres techniques de prédiction mémoire plus élaborées que les précédentes existent. Il s'agit de la prédiction d'adresse et de la prédiction de valeur. Leur nom trahit ce qu'elles font. La première technique tente de prédire l'adresse d'une lecture/écriture, alors que la seconde tente carrément de prédire quelle sera la donnée lue. Il y a bien de la recherche académique sur le sujet, mais il s'agit de techniques assez osées, dont on voit mal comment elles pourraient fonctionner. L'efficacité de cette technique a été étudiée grâce à des simulateurs. Suivant les études ou les programmes, on trouve des résultats qui varient pas mal. Dans certains cas, la performance baisse un peu, et dans d'autres, on peut avoir des gains de plus de 10% ! Mais dans des cas normaux, on trouve des gains de 4-5% environ. Ce qui est tout de même pas mal à l'heure actuelle. Cependant, en Janvier 2025, à l'heure où j'écris ces lignes, il a été découvert la première utilisation de ces deux techniques dans un processeur commercial. La découverte fait suite à la publication de deux vulnérabilités matérielles sur les CPU Apple M2 et M4 et quelques CPU ARM, qui font justement usage de ces deux techniques. Les deux vulnérabilités, appelées SLAP et FLOP, sont assez similaires aux attaques Spectre et Meltdown, sauf qu'elles attaquent non pas la prédiction de branchement, mais la prédiction d'adresse et de valeur du CPU. ===La prédiction d'adresse=== La prédiction d'adresse tente de prédire l'adresse d'une lecture à l'avance, ce qui permet de lancer les lectures en avance. Si la prédiction est correcte, on gagne quelques cycles qui permet aux unités de calcul de faire leurs calculs plus en avance, sans compter que les mécanismes de désambiguïsation mémoire fonctionnent plus efficacement. Si jamais cette adresse est connue plus tôt, on détecte les dépendances plus rapidement et on peut agir en conséquence. Des variantes de ces unités de prédiction d'adresse sont utilisées pour précharger des données depuis le cache de donnée. Et leur efficacité est assez bonne, voire excellente dans certains cas. La différence est que la prédiction d'adresse est utilisée pour prédire les adresses à lire depuis le cache vers les registres, ce qui est le sujet de cette sous-partie. La prédiction d'adresse est réalisée par une sorte de cache, qui associe à chaque instruction la prochaine adresse à lire. Nous appellerons ce cache la '''table de prédiction d'adresse'''. Pour faire l'association instruction <-> adresse lue/écrite, le tag du cache utilise l'adresse de l'instruction (le ''program Counter''), alors que la ligne de cache associée contient l'adresse prédite. Son contenu est utilisé pour faire une prédiction. Lorsqu'une lecture est émise, la table est consultée pour récupérer en avance l'adresse à lire, avant que celle-ci soit calculée par l'ALU. La lecture est alors exécutée en avance, avant que la vraie adresse soit connue, en utilisant l'adresse prédite. Reste qu'il faut des circuits autour de cette table de prédiction d'adresse, pour déterminer si la prédiction est correcte, ainsi que pour remplir cette table avec une adresse prédite. Pour cela, il y a un circuit de prédiction d'adresse dédié. Il récupère les instructions qui quittent le ROB ou la file des lectures mémoire, afin de vérifier les lectures qui viennent de se terminer/''commit'' et qui quittent le pipeline. Il peut contenir une '''table d'apprentissage''', qui mémorise des informations nécessaires pour faire des prédictions d'adresse. Par exemple, un historique des dernières adresses lues pour les lectures les plus récentes, ou un historique des adresses des lectures. La table est aussi utilisée pour déterminer si les prédictions effectuées étaient correctes ou non. Pour cela, il est possible de réutiliser les techniques vues dans le chapitre sur la prédiction de branchement, notamment des compteurs à saturation. Pour la prédiction de branchement et les autres formes de prédiction mémoire, les table d'apprentissage et pour les instructions/adresses sont fusionnées en une seule. Mais ici, il est nécessaire de les séparer pour une raison assez particulière : éliminer la pollution du cache dans la table de prédiction d'adresse. On ne peut pas allouer une ligne de cache pour chaque nouvelle instruction de lecture exécutée. Seule une minorité des lectures profitent de la prédiction d'adresse. Il est nécessaire de filtrer les lectures pour ne conserver que celles qui profitent de la prédiction d'adresse, ce qui est possible plus facilement en séparant la table d'apprentissage et les compteurs à saturation de la table de prédiction d'adresse. Une première méthode consiste à prédire que la lecture lit toujours la même adresse, ce qui lui vaut le nom de '''prédiction d'adresse constante'''. On pourrait se demander pourquoi une telle situation surviendrait. La raison est qu'il arrive que les compilateurs n'arrivent pas à gérer les accès mémoires efficacement. Dans certaines situations un peu compliquées impliquant des pointeurs ou des références, divers phénomènes complexes d'''aliasing'' des pointeurs peuvent générer des relectures intempestives de données en mémoire. Cela peut aussi arriver lorsqu'on compile du code qui provient de plusieurs librairies, ou quand du code lit régulièrement des constantes en mémoire ROM. La prédiction d'adresse constante ajoute un circuit qui analyse les dernières lectures, une fois qu'elles quittent le ROB, qu'elles ''commit''. Si, pour une instruction de lecture, l'adresse lue est toujours la même, alors l'instruction est ajoutée dans la table de prédiction d'adresse. Vu que les instructions qui accèdent toujours à la même adresse sont rares, il est préférable d'initialiser les compteurs à saturation de façon à ce qu'ils disent que toute nouvelle instruction change d'adresse. Il s'agit d'une méthode simple mais peu efficace, car cette situation est rare. Une autre méthode détecte les accès en enjambées, là encore quand les lectures quittent le ROB. Nous l’appellerons la '''prédiction d'adresse en enjambées'''. De tels accès surviennent régulièrement quand on accède des tableaux ou d'autres structures de données semblables, ce qui rend la technique très intéressante. Implémenter cette technique est facile : il suffit d'ajouter un circuit qui détecte les accès en enjambées, au lieu de celui qui détecte les accès constants. À chaque fois qu'une lecture entraine un succès de cache dans la table de prédiction d'adresse, l'enjambée est ajoutée à l'adresse contenue dans la table de prédiction d'adresse, l'ancienne adresse prédite est remplacée par celle avec l'enjambée d'ajoutée. Sur les Apple M2-M4, la prédiction d'adresse gére aussi bien les adresses constantes que les accès en enjambée. Le brevet qui décrit l'implémentation de l'unité de prédiction d'adresse et de valeur est potentiellement le suivant : [https://patents.google.com/patent/US11829763B2/en Early load execution via constant address and stride prediction]. En théorie, il est possible de faire mieux en repérant des accès mémoires qui se répètent de façon régulière et cyclique, même s'ils n'ont aucune enjambée. Ce genre d'accès se trouve assez souvent lorsque l'on manipule des listes chainées ou des structures de données assez irrégulières comme des arbres, des graphes, etc. Mais il n'y a pas encore de CPU qui implémente de telle optimisation. ===La prédiction de valeur : prédire la donnée lue=== Les techniques de '''prédiction de valeur''' (''Value Prediction'') consistent à prédire quelle est la donnée qui sera lue par une instruction de lecture. Oui, vous avez bien lu : le processeur est capable de parier sur la valeur qui sera chargée depuis la mémoire, de prédire si cette valeur vaut 0, 1, 1024, etc. Une fois son pari fait, il exécute les instructions du programme avec la valeur qu'il a pariée de façon spéculative. Si le pari est correct, le CPU continue l’exécution. Sinon, il est obligé de vider le pipeline et de recommencer avec la bonne valeur. Au premier abord, cette technique semble franchement mal partie tellement les chances de se tromper sont tellement énormes ! Mais le fait est que, dans certains cas, il est possible de spéculer correctement. Là encore, les techniques les plus simples fonctionnent dans deux cas. Le premier est celui où une donnée est relue à l'identique à chaque fois que la lecture est exécutée. Le second est celui où la donnée est incrémentée/décrémentée d'une constante qui est toujours la même d'une exécution de la lecture à l'autre. Une sorte d'enjambée constante, mais pour les données. Nous allons nous concentrer sur le premier cas, celui où une donnée est relue plusieurs fois sans être modifiée entre temps. L'idée de base est qu'une instruction de lecture qui s'exécute plusieurs fois de suite à la même adresse renvoie la même valeur à chaque fois. C'est parfaitement possible s’il n'y a eu aucune écriture à cette adresse entre-temps, d'où le fait qu'il faille surveiller si les prédictions constantes sont correctes. On peut se demander quelles sont les raisons qui font qu'une instruction de lecture renvoie la même valeur à chaque fois. Après tout, autant lire une seule fois la donnée et la garder dans un registre une bonne fois pour toutes ! Mais dans la réalité, on fait face à quelques limites, qui seront détaillées dans la liste suivante. La première est que le manque de registre fait que certaines données sont conservées en RAM et sont relues fréquemment. Cela arrive quand on stocke des constantes en mémoire. Par exemple, sur les processeurs x86, les constantes flottantes ne peuvent pas être intégrées dans nos instructions via le mode d'adressage immédiat. À la place, on les stocke en mémoire et on les charge dans les registres à chaque fois qu'on en a besoin. L'implémentation est similaire à la prédiction d'adresse, si ce n'est que c'est la donnée lue qui est prédite. Là encore, on a une '''table de prédiction de donnée lue''', consultée lorsqu'une lecture est émise. La table de prédiction de la donnée lue est une mémoire cache qui associe le ''Program Counter'' de la lecture à la donnée prédite, éventuellement couplé à des compteurs à saturation. Elle est consultée à chaque fois qu'une lecture est émise, la donnée adéquate est récupérée lors d'un succès de cache. On trouve aussi un circuit qui insère ou retire des instructions de la table de prédiction de donnée lue. Elle détecte les lectures prédictibles, couplé à une table d’apprentissage, et quelques circuits annexes liés au ROB ou aux files de lectures. Elle contient au minimum des compteurs à saturations, associés chacun à une instruction (son ''program counter'' pour être précis). ==L'optimisation du ''Mirroring memory operand''== Les processeurs modernes implémentent une optimisation concernant les accès mémoire. Cette optimisation a été décrite par Agner Fog dans ses manuels d'optimisation, sous le terme de '''''Mirroring memory operand'''''. Il a documenté son apparition sur les processeurs Zen 2 d'AMD, puis son évolution sur les processeurs Zen 4 et 5. L'optimisation est bizarrement absente de la microarchitecture Zen 3. Elle se manifeste dans une condition très précise : plusieurs instructions proches accèdent à la même adresse. Les deux instructions ne sont pas forcément consécutives, il peut y avoir une dizaine ou centaine d'instructions entre les deux. Une telle situation survient souvent sur les CPU CISC, et notamment sur les CPU x86, car ils ont peu de registres. les compilateurs font donc beaucoup d'échanges entre pile d'appel et registres, ce qui fait que des données écrites dans la pile sont souvent relue quelques instructions plus tard. Dans ce cas, la donnée lue/écrite est mémorisée dans un registre interne au processeur et peut être accédée très rapidement. Par exemple, imaginons plusieurs lectures proches à la même adresse, sans écriture entre elles. Dans ce cas, le processeur ne fera qu'une seule lecture en mémoire RAM, les lectures ultérieures liront la donnée depuis le registre interne. Comme autre exemple, imaginons une écriture suivie plus tard par une lecture à la même adresse. Dans ce cas, la donnée sera écrite dans un registre interne, en plus d'être propagée dans le cache ou la RAM, et la lecture ultérieure lira directement le registre interne. Le gain en performance est alors conséquent, comparé à du simple ''store-to-load forwarding''. Il faut noter que la technique marche aussi pour les opérations ''load-op'', pas seulement pour les instructions d'accès mémoire. Par exemple, prenons une écriture en RAM, suivies quelques instructions plus tard par une opération ''load-op'' à la même adresse. L'écriture écrit à une adresse, qui est lue par l'instruction ''load-op''. L'optimisation s'applique : l'instruction ''load-op'' lira l'opérande mémoire depuis un registre interne du processeur, qui contient une copie de la donnée écrite. Sur les processeurs Zen 2, l'optimisation marchait aussi pour les instructions PUSH et POP, qui lisaient/écrivaient dans la pile d'appel. Mais cette possibilité a été retirée sur les processeurs Zen 5. La technique détecte que deux instructions accèdent à la même adresse sous certaines conditions bien précises. L'optimisation ne marche que pour les modes d'adressages indicés et indirect, avec ou sans décalages, mais pas en mode absolu ou relatifs. Il faut préciser que l'optimisation ne marche que pour les registres généraux, pas les registres flottants. Les registres doivent être précisés de la même manière dans toutes les instructions. Par exemple, le processeur ne reconnait pas que l'addition des deux registres RCX + RAX est la même que RAX + RCX. Sur les CPU Zen 2 et 4, elle ne marchait que pour des opérandes de 32 et 64 bits, mais les processeurs Zen 5 gèrent des données de 8 et 16 bits aussi. Les décalages étaient limités à 8 bits sur le Zen 2, mais n'a plus de limites sur les Zen 4 et 5. ==La désambiguïsation mémoire a des effets de bord== La désambiguïsation mémoire a une caractéristique que l'exécution dans le désordre simple n'a pas : elle a des effets qui débordent sur la mémoire. Il est possible de savoir si un processeur fait de la désambiguïsation mémoire en regardant le bus mémoire. On s’aperçoit alors que les lectures se font dans un ordre différent de celui du programme. À l'opposé, l'exécution dans le désordre n'a pas d'effets observables de ce genre. Elle améliore la performance, elle modifie les durées observables des instructions, mais cela se limite à des questions de ''timings''. Le seul moyen de savoir si un processeur fait de l'exécution dans le désordre est de mesurer les latences/durées de chaque instruction. Mais aucun état observable au-delà des ''timings'' n'est modifié. Pas avec la désambiguïsation mémoire. ===La consistance mémoire=== Le fait que la désambiguïsation mémoire ait des effets observables ne pose aucun problème avec un seul processeur, ca elle est conçue pour. Par contre, les techniques de désambiguïsation mémoire peuvent poser des problèmes avec plusieurs processeurs. Il arrive que des programmes partagent une même portion de mémoire, et lisent/écrivent dedans. Avec plusieurs processeurs, chaque processeur effectue la désambiguïsation mémoire dans son coin sans se préoccuper des autres. Les opérations d'écriture peuvent être mises dans le désordre du point de vue des autres processeurs, et les lectures effectuées par les autres processeurs peuvent alors renvoyer de vielles données. Un exemple classique est celui de deux cœurs qui partagent le même cache ou la même mémoire, mais qui utilisent chacun une file d'écriture (''store buffer''). Imaginons que le premier cœur effectue une écriture, puis une lecture, à des adresses différentes. L'écriture est retardée grâce au tampon d'écriture, alors que la lecture lit directement le cache. Pour le premier processeur, l'écriture s'est réalisée avant la lecture, car pour lui, la fin de l'écriture signifie écrire dans le tampon d'écriture. Mais pour le second cœur, l'ordre s'est inversé : il voit la lecture dans le cache, puis l'écriture dans le cache. La raison est qu'il n'a pas connaissance du contenu du tampon d'écriture de l'autre cœur. Un autre exemple : les techniques de prédiction d'adresse et de valeur posent des problèmes avec les dépendances RAW. Elles permettent à une instruction mémoire de s'exécuter avant une autre, malgré la présence d'une dépendance RAW entre les deux. L'ordre entre les deux instructions change, alors que les autres processeurs ne devraient pas le voir. Et dans les faits, les processeurs qui incorporent ces techniques ont des modèles de consistance mémoire qui ne permettent pas ces optimisations en cas de dépendances RAW. Les deux techniques doivent idéalement être désactivées pour les instructions mémoire qui ont des dépendances RAW. Les programmeurs doivent tenir compte de ce genre de situations, dans des cas assez rares et liés à la programmation bas niveau. Pour cela, ils doivent savoir comment le processeur réorganise les accès mémoire. Concrètement, chaque processeur définit un '''modèle de consistance mémoire''' qui indique quelles optimisations sont activées et non. Il s'agit d'une sorte de contrat entre le logiciel et le matériel : les programmeurs savent comment le processeur va réorganiser les lectures et écritures et peuvent coder leurs programmes en fonction. L'implémentation du système de consistance mémoire dépend grandement de l'architecture, mais des modèles ont été standardisés afin de permettre aux programmeurs de savoir où placer les barrières mémoires. Il s'agit de vrais standards, formalisés, parfois mathématiquement, que des chercheurs étudient sur un plan mathématique et algorithmique. Le modèle préféré des programmeurs est la '''consistance séquentielle''', où les différents processeurs effectuent des accès mémoire dans l'ordre du programme et les accès mémoire simultanés sont interdits. La première contrainte interdit l'usage de mécanismes de désambiguïsation mémoire, la seconde interdit l'usage de caches non-bloquants. La consistance séquentielle a donc un cout en performance assez important, ce qui fait que les processeurs modernes n'utilisent pas la consistance séquentielle. Un modèle plus flexible est le '''''total store ordering''''', qui autorise la présence d'une file d'écriture et l'usage de caches non-bloquants pour les lectures (interdit d'avoir plusieurs écritures simultanées). C'est plus ou moins celui utilisé par les processeurs x86, comme on le verra plus bas. Il existe des modèles de consistance mémoire plus relâchés, appelés des '''modèles de consistance faible''', qui autorisent diverses optimisations de spéculation mémoire, la combinaison d'écriture (fusionner deux écritures à la même adresse en une seule), etc. Ils sont supportés sur les architectures ARM et POWER. ===Les barrières mémoire=== Le changement d'ordre des lectures et écritures peut poser occasionnellement des problèmes avec la programmation pour les architectures multi-processeur ou multicœurs. Il existe des morceaux de code qui ne marchent que si la consistance séquentielle est respectée et il faut faire en sorte qu'ils marchent sur des processeurs avec un modèle de consistance mémoire différent. Pour cela, les processeurs incorporent des instructions appelées '''barrières mémoires''' ou '''''Fences'''''. Elles forcent le processeur à terminer tous les accès mémoire qui précédent dans l'ordre du programme. Certains processeurs possèdent une barrière mémoire pour les lectures et une autre pour les écritures. D'autres processeurs ajoutent des barrières mémoires dédiées à des cas particuliers. Elles permettent de forcer les accès à des données partagées à dérouler correctement. [[File:Fences.png|centre|vignette|upright=2|Fences.]] Leur implémentation est assez simple : elles empêchent l'émission de toute nouvelle instruction mémoire, tant qu'une condition précise n'est pas réunie. Par exemple, la barrière mémoire pour les écritures attend que la file d'écriture soit vidée avant de démarrer le moindre accès mémoire. Un compilateur peut placer les barrières mémoires au bon endroit, avec un peu d'aide du programmeur. Certains langages de programmation permettent d'indiquer au compilateur qu'une donnée doit toujours être lue depuis la mémoire RAM, via le mot-clé volatile. C'est très utile pour préciser que cette donnée est potentiellement partagée par plusieurs processeurs ou manipulable par des périphériques. Les compilateurs peuvent placer des barrières mémoires lors des lectures ou écritures sur ces variables, mais ce n'est pas une obligation. Il arrive aussi que le programmeur doive manipuler explicitement des barrières mémoires. Utiliser l'assembleur est alors une possibilité, mais qui est rarement exploitée, pour des raisons de portabilité. Pour limiter la casse, certains systèmes d'exploitations ou compilateurs peuvent aussi fournir des barrières mémoires explicites, encapsulées dans des bibliothèques ou cachées dans certaines fonctions. ===La modèle de consistance des processeurs x86=== Après avoir vu la théorie, passons maintenant à la pratique. Dans cette partie, on va voir les modèles de consistances utilisés sur les processeurs x86, ceux qu'on retrouve dans les PC actuels. Le modèle de consistance des processeurs x86 a varié au cours de l'existence de l'architecture : un vulgaire 486DX n'a pas le même modèle de consistance qu'un Core 2 duo, par exemple. Ils ont évolués au cours du temps, avec l'arrivée de nouvelles optimisations. De plus, leur formalisation a été progressive, et s'est faite au fur et à mesure. En général, les modèles de consistance des processeurs x86 ont toujours étés assez restrictifs, si on les compare aux autres processeurs. Il s'agit de modèles de type ''total store ordering'' (TSO), au moins dans les grandes lignes. Ils permettent donc d'effectuer certaines lectures en avance, soit parce que les lectures se font à des adresses différentes, soit par utilisation de réacheminement écriture vers lecture. Les possibilités d'exécution en avance des lectures varient suivant le processeur, elles deviennent de plus en plus performantes avec le temps. Le premier modèle de consistance est apparu sur les premiers processeurs x86 et est resté en place sur tous les processeurs de marque Pentium. Les processeurs de l'époque n'incorporaient pas beaucoup d'optimisations, le budget en transistor n'était pas assez conséquent pour ça, la mémoire n'était pas assez lente. Le modèle TSO de l'époque n'autorisaient que peu de lectures anticipées. Une lecture ne peut être exécutée en avance que sous les conditions suivantes : * les écritures contournées se font dans la mémoire cache ; * la lecture doit se faire dans la mémoire RAM ; * les écritures se font à une adresse différente de la lecture ; * aucune transaction avec un périphérique ne doit être en cours. A partir du Pentium 4, les choses changent. Le Pentium 4 est en effet le premier processeur à implémenter des techniques permettant d’exécuter plusieurs processus en parallèle, dont l'Hyperthreading. En conséquence, le modèle de consistance a dû être assoupli pour éviter de perdre bêtement en performance. Il s'agit d'un vrai modèle de type TSO, la majeure partie des contraintes sur les lectures anticipées sont réduites au minimum. Il y a aussi des contraintes supplémentaires pour les instructions dites atomiques, ainsi que pour les instructions qui accèdent aux périphériques ou aux entrées-sorties. Une optimisation séparée du TSO est que les écritures en mémoire peuvent changer d'ordre dans un cas exceptionnel : les écritures effectuées par les instructions de copie mémoire comme REPMOVSD, REPSCACB, ainsi que les instructions SSE MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, MOVNTPD. Les écritures effectuées dans ces instructions peuvent se faire dans un désordre complet (ou presque). Lors du passage au 64 bits, le modèle mémoire des processeurs x86 de l'époque a été formalisé dans un papier d'Intel appelé [https://www.cs.cmu.edu/~410-f10/doc/Intel_Reordering_318147.pdf Intel® 64 Architecture Memory Ordering White Paper], mis à jour en 2008 suite à quelques problèmes techniques. La formalisation du modèle a été d'une grande aide aux programmeurs, qui devaient se débrouiller avec des documentations qui ne formalisaient pas de modèle de consistance précis. Le document décrit un modèle de consistance mémoire portant le nom barbare de ''total lock order + causal consistency” (TLO+CC)'', qui admet plus d'optimisations que le modèle TSO. Mais la description était en réalité légèrement différente du modèle mémoire réellement implémenté sur les processeurs existants, qui utilisaient du TSO, sauf en de rares occasions. Les concepteurs de processeur font souvent cela, à savoir définir un modèle de consistance plus laxiste que réellement implémenté, pour des questions de comptabilité. Ainsi, si les futurs processeurs de la marque implémentent un modèle plus laxiste, les programmes déjà codés continuent de fonctionner. Le modèle décrit dans la documentation laisse de la marge pour le futur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le parallélisme mémoire | nextText=Le parallélisme mémoire }} </noinclude> 77pu23j96a7sev42119sr6rd7y3x6vq 774139 774138 2026-10-04T14:09:33Z Mewtow 31375 /* L'exécution anticipée des calculs d'adresse */ 774139 wikitext text/x-wiki Dans les chapitres précédents, nous avons vu la mal-nommée exécution dans le désordre dans les chapitres précédents, qui devrait plutôt s'appeler l'émission dans le désordre. Elle modifie l'ordre des instructions pour gagner en efficacité, mais cela n'est valable que pour les instructions travaillant sur des registres ! Nous n'avons pas parlé de l'exécution dans le désordre des accès mémoire, car ils sont un peu à part. Et pour comprendre pourquoi, nous allons dédier ce chapitre à la gestion des accès mémoire dans le pipeline. Avant de poursuivre, faisons un rappel rapide sur les dépendances de données. Il faut distinguer deux types de dépendances de données : les dépendances de registres (deux instructions manipulent le même registre), et les dépendances d'adresse (deux instructions manipulent la même adresse). Deux instructions ont une dépendance d'adresse si elles écrivent ou lisent la même adresse mémoire. Des instructions mémoires qui lisent/écrivent des adresses différentes sont indépendantes. Lorsque deux instructions mémoire lisent/écrivent à la même adresse, on parle d''''aliasing mémoire'''. Formellement, l'aliasing mémoire n'est ni plus moins que l'application des dépendances de données aux instructions mémoire. Et comme les autres dépendances de données, il existe plusieurs types d'aliasing mémoire : RAR, RAW, WAR et WAW. Les dépendances RAR ne posent aucun problème, les lectures peuvent changer d'ordre sans aucun problème, tant qu'il n'y a pas d'écritures entre les deux. Par contre, les autres dépendances imposent trois contraintes. * Les dépendances RAW imposent que les lectures ne doivent pas s'exécuter avant une écriture à la même adresse. * Les dépendances WAR imposent que l'on ne peut pas déplacer une écriture avant une lecture à la même adresse. * Enfin, les dépendances WAW font que l'ordre des écritures doit être celui du programme : impossible de changer l'ordre de deux écritures. Les trois contraintes peuvent s'implémenter de plusieurs manières différentes. Comme pour les dépendances de données normales, seules les dépendances RAW sont de vraies dépendances de données. Les deux autres dépendances viennent du fait que l'on utilise la même adresse pour stocker des valeurs différentes. Détecter les cas d'''aliasing'' mémoire est primordial pour gérer la désambiguïsation mémoire, et cela demande de comparer des adresses. S'il existe des techniques similaires au renommage de registre permettent de les faire disparaitre, elles sont très limitées. Diverses optimisations permettent d'émettre des accès mémoire dans le désordre, exactement comme l’exécution dans le désordre le fait pour les autres instructions. Elles utilisent pour cela des ''schedulers'' spécialisées dans les accès mémoire. Il faut vraiment insister sur le fait que l'exécution dans le désordre proprement dite ne se préoccupe pas des micro-opérations mémoire. Pour faire la distinction, on parle de '''désambiguïsation mémoire''' (''memory disambiguation'') pour parler de l'exécution dans le désordre des accès mémoire. Le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. La raison est que la donnée lue est utilisée par d'autres instructions, dépendantes de la lecture. Elles sont donc à la tête d'une chaine de dépendances de données qui peut bloquer le pipeline si elle n'est pas résolue très tôt. Dès qu'une lecture peut être lancée, elle accède directement au cache de données. Par contre, si l'''aliasing'' ne le permet pas (écriture à la même adresse pas encore effectuée) ou que l'adresse à lire n'a pas encore été calculée, la lecture attend son tour. Le problème est que la désambiguïsation mémoire demande de comparer des adresses, pour détecter les dépendances d'adresse. Et cela pose de sérieuses contraintes pour son implémentation. L'exécution dans le désordre normale compare des registres, ce qui est beaucoup plus simple. Les registres utilisés sont directement encodés dans l'instruction elle-même, ce qui fait qu'on peut détecter les dépendances de registre après le décodage. Et elles peuvent même être éliminées par le renommage de registre pour les dépendances WAW et WAR. Par contre, les adresses ne sont pas encodées dans l'instruction elle-même (sauf avec le cas particulier de l'adressage absolu), mais sont lues depuis les registres ou calculées par une unité de calcul. Les adresses ne sont donc connues qu'une fois que l'instruction a attendu suffisamment de temps dans le ''scheduler''. ==L’émission dans l'ordre des accès mémoire== Avant toute chose, faisons quelques rappels sur l'exécution dans le désordre "normale", sans désambiguïsation mémoire. Les micro-opérations renommées sont mises en attente dans un ''scheduler'', le temps que leurs opérandes soient disponibles. Les µops mémoire font cependant exception. ===La file de µops mémoire=== Avec l''''émission dans l'ordre des accès mémoire''', le processeur émet les accès mémoire dans l'ordre du programme. Cela implique que l'unité mémoire n'a pas de ''scheduler'', ni de station de réservation associée. À la place, les micro-opérations mémoire sont placées dans une file de micro-opération dédiée, appelée la '''file de µops mémoire'''. La file de µops mémoire est une mémoire FIFO, dont chaque entrée peut accueillir une µops mémoire. [[File:Unité de gestion des accès mémoires dans l’ordre.png|centre|vignette|upright=2.0|Unité de gestion des accès mémoires dans l’ordre]] Par contre, le processeur utilise des ''schedulers'' pour les unités de calcul. Autant les accès mémoire se font dans l'ordre, autant les instructions arithmétiques/logiques/branchements/autres sont exécutées dans le désordre. En clair, le processeur supporte l'exécution dans le désordre, sauf pour les micro-opérations mémoire. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] Vu de l'extérieur, il faut attendre qu'un accès mémoire soit terminé pour lancer le suivant. Avant d'émettre une micro-opération mémoire, la file de micro-opération (son ''scoreboard'') vérifie si l'unité d'accès mémoire est libre ou non. Si elle est occupée, un accès mémoire est en cours, et on ne peut pas émettre de micro-opération mémoire. Une fois l'accès mémoire terminé, la mémoire ou le cache envoient un signal pour indiquer que l'accès est fini, et c'est au tour d'une nouvelle micro-opération mémoire. L'unité mémoire a un port sur lequel on envoie la micro-opération émise, que ce soit une écriture ou une lecture. Une file de µops implique la ''lecture après émission'', à savoir que les µops mémoire quittent la file de µops, avant de lire les registres adéquats. Il y a donc une connexion entre banc de registre et unité mémoire, comme sur n'importe quel autre processeur. Mais la file de µops n'est pas connectée au banc de registre, ni au réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] L'organisation précédente laisse penser que les calculs d'adresse sont forcément réalisés dans l'unité mémoire. Mais ce n'est pas une obligation. Il est parfaitement possible de scinder une µops mémoire en deux : une µop mémoire proprement dite et une µop de calcul d'adresse. La liaison entre les deux doit cependant se faire en utilisant un registre. L'adresse calculée est enregistrée dans un registre d'adresse "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. ===La file d'écriture : une FIFO de mise en attente des écritures=== L'unité mémoire exécute une µops mémoire à la fois. Avec un détail particulier : les écritures doivent attendre que toutes les instructions se terminent, avant de pouvoir s'exécuter. Sans ça, les exceptions précises sont impossibles, la prédiction de branchement l'est aussi et l'exécution dans le désordre aurait des conséquences fâcheuses (en exécutant des écritures en avance). Précisément, les écritures doivent respecter deux contraintes : s'exécuter dans le tout dernier étage du pipeline, s'exécuter dans l'ordre du programme. Il serait possible de bloquer l'émission des écritures tant que le ROB ne donne pas son accord. Mais ce n'est pas du tout ce qui est fait. A la place, les écritures sont mises en attente dans une mémoire FIFO, appelée la '''file d'écriture''', ou encore la ''Write Queue''. Les écritures sont mises en attente, jusqu'à ce que le ROB leur donne le okay pour transfer l'écriture en mémoire RAM. Nous en avions déjà parlé dans le chapitre sur le pipeline, dans la section sur les exceptions précises. Mais quelques rappels ne feront pas de mal. L'écriture est insérée dans la file d'écriture lorsqu'elle est émise, elle en sort à l'étage de ''Writeback'' si tout se passe bien. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente, faites à tort. De plus, la file d'écriture est une mémoire FIFO, qui mémorise les écritures dans l'ordre du programme. En effet, les écritures sont ajoutées dans la file d'écriture lorsqu'elles sont émises, et elles sont émises dans l'ordre du programme. [[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Write Queue sur un pipeline fixe]] La file d'écriture garantit que les écritures sont dans l'ordre du programme. Et cette propriété élimine totalement les dépendances WAW. Le fait que les écritures se font à la fin du pipeline élimine quant à elle les dépendances WAR. Il ne reste plus qu'à gérer les dépendances RAW, celles où une lecture lit une donnée écrite par une autre instruction. Une telle situation survient, car les écritures sont mises en attente pendant un certain temps, la donnée écrite peut être lue pendant ce laps de temps. La lecture ne peut pas lire la donnée dans le cache, car la donnée à lire n'y est pas encore enregistrée. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire, mais il existe. Il se manifeste surtout sur les processeurs avec peu de registre, où des données doivent régulièrement être déplacées temporairement en mémoire RAM. Pour éviter cela, une solution consiste mettre en attente les lectures si une écriture est en attente. Les lectures sont maintenues dans la file de µops tant que la file d'écriture n'est pas vide. La file de µops mémoire est suivie d'un circuit de détection des dépendances, un '''''scoreboard'' mémoire''' qui est assez simple. L'unité mémoire fournit un bit qui indique si la file d'écriture est vide ou non, ainsi qu'un second bit qui indique qu'elle est vide. Les deux ne sont pas compliqués à générer, ils font partie intégrante de l'interface de toute mémoire FIFO digne de ce nom. Le ''scoreboard'' mémoire utilise ces deux bits pour savoir s'il peut émettre une micro-opération mémoire. Il n'émet pas de lecture tant que la file d'écriture n'est pas vide. Il peut par contre émettre des écritures tant qu'elle n'est pas pleine. Une meilleure solution serait d'interdire les lectures, mais seulement en cas de dépendance mémoire détectée. Et c'est là qu'intervient la technique du '''''load bypassing'''''. Elle autorise une lecture, si elle n'a aucune dépendance avec les écritures mises en attente dans la file d'écriture. Elle doit être mise en attente dans le cas contraire. Pour l'implémenter, il faut comparer l'adresse à lire avec toutes les adresses dans la file d'écriture. La lecture est exécutée immédiatement si aucune correspondance n'est trouvée, mais elle est mise en attente dans le cas contraire. Pour cela, la file d'écriture est modifiée et devient un mélange entre FIFO et mémoire associative. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'' : la lecture doit être bloquée. Sinon, c'est un défaut de ''Write Queue'', la lecture accède au cache, là où se trouve la donnée à lire. Dans le chapitre sur le contournement, nous avons parlé d'une forme de contournement qui corrige ce problème. Les lectures peuvent lire la donnée depuis la file d'écriture si besoin, ce qui renvoie la donnée adéquate. Pour cela, les lectures consultent à la fois le cache et la file d'écriture, comme dit plus haut. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'', ou encore de '''réacheminement écriture-vers-lecture'''. [[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]] La lecture est mise en attente dans l'unité mémoire, vu que c'est elle qui fait les comparaisons d'adresse. Du point de vue extérieur, le ''scoreboard'' mémoire ne voit pas s’il y a eu succès dans la file d'écriture ou non. Il voit juste que l'unité mémoire est occupée et ne peut pas accepter de nouvelle lecture. Elle reste occupée durant quelques cycles, en cas de défaut de ''Write Queue'', le temps de faire la lecture dans le cache. Par contre, en cas de succès de ''Write Queue'', elle reste occupée tant que la lecture n'a pas donné de résultat, ce qui inclue le temps de mise en attente. Les processeurs haute performance implémentent tous le ''Store to load Forwarding'', mais quelques processeurs basse performance ne le font pas. Ils se contentent du ''load bypassing'', qui offre des performances correctes pour un cout en matériel bien plus modeste. Pour donner un exemple, la micro-architecture Jaguar d'AMD était dans ce cas. Elle a été utilisée sur les consoles de jeu de 8ème génération, comme la Xbox One et la Playstation 4. Les processeurs haute performance préférent utiliser plus de circuits pour implémenter le ''Store to load Forwarding'', mais l'implémentation n'est souvent pas complète. En effet, l'implémentation est souvent simple et ne prend en compte que des lectures/écritures à des adresses identiques, sans compter que la taille des données doit aussi être identique. Une écriture d'un entier de 32 bit sera contournée et envoyée à une lecture de 32 bits à la même adresse. Mais la lecture lit seulement 16 bits sur les 32 écrits, le contournement peut ne pas marcher. Concrètement, ce cas précis fonctionnera. Mais le cas inverse, avec une lecture lisant plusieurs écritures de taille plus petite, ne marchera pas sur tous les processeurs. Ne parlons pas du cas où les données écrites sont à cheval sur deux lignes de cache. ==L'exécution anticipée des calculs d'adresse== L'exécution dans le désordre fonctionne mieux si les lectures sont exécutées le plus tôt possible. Les lectures lisent des données qui sont utilisées par d'autres instructions. Si la lecture tarde, les autres instructions sont mises en attente. Exécuter les lectures le plus tôt possible est donc crucial. Mais comment faire ? Un moyen pour ça est de faire en sorte que les adresses à lire/écrire soient connues le plus tôt possible. Mais cela implique que les calculs d'adresse ne sont pas fait en même temps que l'accès mémoire. Pour cela, il est possible de séparer les µops mémoire en deux micro-instructions : une pour le calcul d'adresse et l'autre pour les accès mémoire. On parle de '''calcul anticipé des adresses'''. Avant de poursuivre, un minimum de terminologie. Nous parlerons d'AGU pour désigner les unités de calcul d'adresse, ce qui est une abréviation de ''Adresse Generation Unit''. Nous parlerons aussi de '''µops AGU''' pour parler des µops de calcul d'adresse. Le terme est de moi, il est assez parlant : "µops exécutés dans les AGUs". Nous utiliserons ce terme pour les séparer des µops entières classiques, comme les additions/soustractions/multiplications arithmétiques, les décalages/rotations, etc. Le calcul anticipé des adresses est utilisé avec ou sans désambiguïsation mémoire. Elle a des avantages avec et sans. Sans, elle calcule les adresses en avance, ce qui fait que des lectures/écritures mises en attente ont leurs opérandes prêtes plus rapidement. Avec, cela permet de connaitre les adresses en avance, ce qui permet de détecter plus rapidement les dépendances d'adresse. Mais laissons cela pour plus tard. ===La ''Load/Store Queue'' : une file de µops améliorée=== Implémenter cette technique demande de faire deux choses. Premièrement, il faut faire les calculs d'adresse en-dehors de l'unité mémoire, soit dans une ALU entière, soit dans une AGU dédiée. Deuxièmement, les adresses calculées doivent être mises en attente quelque part, et être liées facilement à la µop mémoire associée. Pour le second point, la file de µops mémoire parait être un bon candidat, mais n'est pas exactement la solution. Une file de µops au sens strict ne mémorise pas les opérandes des µops, donc pas d'adresse. Cela implique la lecture après émission, ce qui veut dire que l'adresse à lire/écrire est lue depuis les registres une fois que la µops sort de la file de µops (idem pour la donnée à écrire). En pratique, cette implémentation rend difficile de faire les calculs d'adresse ailleurs que dans l'unité mémoire. Heureusement, il est possible d'adapter la file de µops pour gérer la lecture avant émission. L'idée est qu'elle mémorise, pour chaque µop mémoire, l'adresse à lire/écrire et d'autres informations. Il ne s'agit donc plus vraiment d'une file de µops mémoire mais quelque chose d'autre, une sorte d'intermédiaire entre une station de réservation et une file de µops. D'un côté, elle fonctionne comme une file de µops car elle émet les µops dans l'ordre du programme. De l'autre, elle mémorise les opérandes des µops mémoire, ce qui en fait une pseudo-station de réservation. Une telle structure est appelée une '''''Load/Store Queue''''' (LSQ). Une LSQ contient plusieurs entrées, chaque entrée pouvant accueillir une µops mémoire et ses opérandes. Une entrée contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures. Les deux derniers champs sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. Elle contient aussi des bits de validité, qui indique si l'adresse ou la donnée sont disponibles, ou si le champ associé est vide. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2|Load Store Queue unifiée]] La LSQ doit faire le lien entre une adresse calculée par l'AGU, et l'entrée associée de la LSQ. Pour cela, l'unité d'émission associe un '''identifiant LSQ''' à une µops mémoire. L'identifiant est généré à l'émission. Aussi bien la µops de calcul d'adresse que a µop mémoire ont toutes deux le même identifiant. L'identifiant est propagé avec la µop de calcul d'adresse, comme tout signal de commande. En sortant de l'AGU, l’identifiant est transmis à la LSQ en même temps que l'adresse. La LSQ regarde quelle entrée a cet identifiant, et mémorise l'adresse dedans. La gestion des données à écrire se fait elle avec le système de contournement normal. La LSQ sait dans quel registre se trouve la donnée à écrire, elle surveille juste le système de contournement pour détecter quand la donnée est disponible. ===Le contournement des adresses mémoire avec une LSQ=== Avec une LSQ, les registres sont lus quand la µop mémoire est insérée dans la LSQ, si elles sont disponibles. Si elles ne sont pas encore disponibles, elles seront envoyées par le système de contournement plus tard. Cela implique qu'il y ait un système de contournement, qui relie les ALUs entières à la LSQ. Les ALUs envoient leur résultat à la LSQ, qui place alors l'adresse calculée dans la bonne entrée, la bonne "µop". [[File:Load-Store Queue.png|centre|vignette|upright=2|Load-Store Queue]] Une autre solution utilise des AGU spécialisées. L'avantage est que cela simplifie grandement le réseau de contournement : seules les AGU sont connectées à la LSQ. Un autre avantage est lié à l'adressage de la destination. En utilisant des ALU entières, il faut préciser que le résultat est à destination de la LSQ, pour configurer le réseau de contournement. Avec des AGU,, l'adresse calculée est envoyée automatiquement à la LSQ, pas besoin de préciser la destination. [[File:ALU et LSQ.png|centre|vignette|upright=2|AGUs et Load-Store Queue.]] Dans les deux cas, les µops AGU sont alors considérées comme des opérations entières un peu spéciales, gérées par le système d'exécution dans le désordre. Elles peuvent être émises dans le désordre, mais la LSQ se chargera de faire les accès mémoire dans l'ordre. Il y a donc calcul des adresses dans le désordre, mais accès mémoire dans l'ordre. ==Les ''schedulers'' mémoire unifiés== Avec la technique précédente, les µops mémoire s'exécutent dans l'ordre, à savoir que l'on ne peut pas déplacer une lecture avant ou après une autre. Il arrive que des lectures prêtes soient mises en attente, parce qu'une lecture précédente attend ses opérandes. Et ce retard sur les lectures se répercute sur les instructions lecture-dépendantes, donc sur le reste du pipeline.Les conséquences sur les performances sont assez importantes. Mais avec l'implémentation qui va suivre, il est possible exécuter les lectures dans le désordre tant que les conditions d'''aliasing'' sont respectées. L'implémentation remplace les files de µops mémoire par un ''scheduler'' spécialisé dans les µops mémoire. Il sera appelé le '''''scheduler'' mémoire''' dans ce qui suit. [[File:Processeur avec émission dans le désordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans le désordre des accès mémoire]] Une entrée du ''scheduler'' mémoire contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures (inutile pour les écritures). Elle contient aussi des bits de validité pour chaque champ qui indiquent si l'adresse d'accès est connue, ou si la donnée à écrire est disponible pour une écriture. Les champs pour la donnée à écrire ou le registre de destination sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2.5|''Load/Store Queue''.]] Lorsqu'une micro-opération mémoire est émise, elle est insérée dans le ''scheduler'' mémoire. Si l'adresse à lire/écrire est disponible, elle est immédiatement lue depuis les registres, idem pour une éventuelle donnée à écrire. Sinon, la micro-opération attend dans le ''scheduler'' mémoire que son adresse soit calculée par le processeur. ===Les calculs d'adresse avec un ''scheduler'' mémoire=== Le ''scheduler'' mémoire détecte des dépendances d'adresse entre instructions. Pour cela, il compare toutes les adresses à lire/écrire. Il mémorise donc les dites adresses, ce qui en fait une station de réservation. Et cela implique que ces adresses ont été calculées ailleurs, dans une ALU entière, ou dans une AGU séparée de l'unité mémoire. L'implémentation la plus simple effectue les calculs d'adresse dans les ALU entières. La sortie de toutes les ALU entière est reliée au ''scheduler'' mémoire. [[File:Contournement pour la file de µops mémoire.png|centre|vignette|upright=2|''scheduler'' mémoire]] Une autre implémentation utilise des unités de calcul d'adresse dédiées, afin de simplifier le réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse via AGU.png|centre|vignette|upright=2|ALU et ''scheduler'' mémoire]] Une conséquence est que l'implémentation impose que toutes les instructions mémoire sont découpées en deux µops : une µop pour calculer l'adresse, et une µop mémoire. C'est un défaut assez important, car une instruction mémoire prendra deux places dans le ROB, dans les stations de réservation, etc. Vu que les instructions mémoire sont très fréquentes, la capacité du ROB/autres est alors fortement réduite. Il faut noter que les adresses calculées dans l'ALU/AGU doivent atterrir dans l'entrée adéquate, celle de la µops qui doit recevoir l'adresse. La seule solution est que le ''program counter'' de la µops est transmis dans le pipeline, propagé avec la µops de calcul d'adresse. Quand le résultat est calculé, le ''program counter'' associé est envoyé avec au ''scheduler'' mémoire. Le ''scheduler'' mémoire enregistre alors l'adresse dans l'entrée qui est taguée avec cette adresse. ===La détection des dépendances mémoire=== Le ''scheduler'' mémoire intègre un système de détection des dépendances, qui est bien différent de celui d'une station de réservation normale. Les ''schedulers'' mémoire comparent non pas des noms de registres, mais des adresses/opérandes. Pour détecter les dépendances, le ''scheduler'' mémoire compare les adresses à lire/écrire. Pour les µops dont l'adresse est inconnue, le cas dépend de l'implémentation. La version la plus conservatrice suppose qu'elle a des dépendances possibles avec toutes les autres. Chaque micro-opération en attente compare son adresse avec celle de toutes les micro-opérations précédentes. Ou presque, la comparaison ne doit tenir compte que des écritures, et de celles situées avant dans l'ordre du programme. Le ''scheduler'' mémoire doit donc mémoriser les instructions dans l'ordre du programme, tout en faisant des comparaisons d'adresse. Ça en fait une sorte hybride entre mémoire associative et mémoire FIFO. Nous avions vu dans le chapitre "L'exécution dans le désordre" qu'il y a plusieurs manières pour implémenter une station de réservation : avec une mémoire associative, avec préplanification, avec des matrices de dépendances. Le problème est qu'on ne peut pas utiliser une mémoire associative pour le ''scheduler'' mémoire, qui utilise des comparateurs pour comparer les noms de registres/adresses. En effet, la détection des dépendances mémoire demande de faire beaucoup de comparaisons : chaque adresse est comparé à toutes les autres. Et les adresses faisant facilement 32 à 64 bits, les comparateurs sont bien plus imposants que ceux qui comparent des noms de registres de 5-10 bits. Le cout en matériel serait trop élevé, de même que le temps de calcul des dépendances. À la place, on utilise des matrices de dépendances. La détection des dépendances mémoire utilise précisément deux matrices de ce type : une matrice de disponibilité, et une matrice de dépendance. Ces matrices forment une espèce de tableau carré, organisé en lignes et en colonnes. L'instruction dans la énième entrée se voit attribuer la énième ligne et la énième colonne. À l'intersection d'une ligne et d'une colonne, on trouve un bit qui indique si l'instruction de la ligne et celle de la colonne ont une dépendance. La '''matrice de disponibilité''' permet de bloquer l'exécution des lectures/écritures tant qu'une instruction précédente n'a pas calculé son adresse, tant qu'on ne peut pas vérifier les dépendances. Lorsqu'une instruction est ajoutée dans le ''scheduler'' mémoire, elle met tous les bits de la colonne attribuée à 1 si l'adresse à lire/écrire est inconnue, puis les met à 0 une fois l'adresse calculée. Une instruction ne peut pas démarrer si, sur la ligne qui lui est attribuée, se trouve un 1. Plus précisément, si on trouve un 1 pour les instructions plus anciennes, ce qui fait qu'une partie de la matrice est ignorée. Les bits notés X dans le tableau suivant sont ignorés, car ils précisent la disponibilité des opérandes d'un opérande ultérieure, ce qui est inutile. En clair, les calculs d'adresse modifient les colonnes, et on vérifie les lignes pour autoriser l'exécution. {|class="wikitable" |- ! Instructions mémoire dans la LSB, triées de la plus ancienne à la plus jeune ! 0 !! 1 !! 2 !! 3 !! 4 !! 5 !! 6 |- ! 0 | 1 || X || X || X || X || X || X |- ! 1 | 0 || 0 || X || X || X || X || X |- ! 2 | 0 || 1 || 0 || X || X || X || X |- ! 3 | 0 || 1 || 1 || 1 || X || X || X |- ! 4 | 0 || 0 || 0 || 0 || 1 || X || X |- ! 5 | 0 || 0 || 1 || 0 || 0 || 1 || X |- ! 6 | 1 || 1 || 1 || 1 || 1 || 1 || 0 |} Les '''matrices de dépendances''' fonctionnent sur le même principe, mais déterminent les dépendances mémoire. Lorsque le processeur a calculé une adresse, il la compare avec les adresses à lire/écrire déjà dans le ''scheduler'' mémoire. Il met alors à jour les bits de la colonne de la matrice de dépendance : le bit à l'intersection d'une ligne et d'une colonne est mis à 0 si les deux instructions n'ont pas de dépendances, à 1 si elles en ont une. Une instruction mémoire est exécutée si tous les bits de sa ligne sont à 0. Là encore, on ne tient compte que des bits pour les instructions plus anciennes, la matrice a encore une fois une forme triangulaire, comme pour la matrice de disponibilité. Une optimisation scinde le ''scheduler'' mémoire en deux structures, aux rôles légèrement différents. La première gère la disponibilité des opérandes, la seconde gère seulement les dépendances mémoire. La première est une station de réservation où les instructions sont mises en attente tant que leurs opérandes ne sont pas prêts. La seconde met en attente des micro-opérations dont les opérandes sont prêts, mais qui doivent attendre pour une autre raison : dépendance mémoire, cache occupé, etc. L'avantage est qu'on remplace une grosse station de réservation par deux plus petites, mais l'intérêt est surtout relié au système de détection des dépendances. Sans cette optimisation, on a deux matrices énormes reliées à une ''Load/Store Queue'' unique. Avec la séparation, la matrice de disponibilité est reliée uniquement à la première station de réservation, alors que la matrice de dépendance est reliée uniquement à l'autre. Les deux matrices sont donc plus petites, car reliées à deux stations de réservation mémoire plus petites. ==L'exécution spéculative des accès mémoires== Pour rappel, le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. Les données lues sont en effet, le départ d'une série d'instructions dépendantes, généralement des instructions arithmétiques, mais parfois d'autres lectures. Quand on manipule des structures de données, il n'est pas rare qu'une lecture lise un pointeur-adresse utilisé par une autre lecture. Les techniques de désambiguïsation mémoire que nous avons vu précédemment sont dites conservatrices, à savoir qu'elles ne changent pas l'ordre de deux instructions mémoire si elles ont une dépendance d'adresse. Elles exécutent une lecture le plus tôt possible, mais seulement si on sait que celle-ci n'aliase pas d'écritures précédentes. Et cela demande de connaitre les adresses des écritures précédentes dans l'ordre du programme. Si une lecture est précédée par une ou plusieurs écritures, les adresses à écrire doivent être connues. Il se peut en effet qu’une écriture dont on ne connait pas l'adresse aliase la lecture. La garantie est liée à la présence de la file de µops mémoire ou la file de µops STORE. Si une seule écriture a une adresse inconnue, elle reste dans la file de µops mémoire et bloque l'émission de toutes les autres micro-opérations mémoire, lecture comme écriture. Mais il existe des techniques dites spéculatives, qui modifient l'ordre des accès mémoire sans se soucier de leurs dépendances. Elles émettent les lectures dès que leurs opérandes sont prêts, sans se préoccuper de l'aliasing avec une écriture précédente. Pour éviter tout problème, le processeur vérifie si aucune dépendance mémoire n'est violée et corrige le tir dès qu'une violation est détectée. Le processeur vérifie s'il a fait une erreur de prédiction, en vérifiant les adresses à écrire ou lire. En cas d'erreur, le processeur fait comme lors d'une prédiction de branchement ratée : il remet le pipeline en ordre et ré-exécute les accès mémoire dans le bon ordre. Il s'agit d'un cas particulier d’'''exécution spéculative''', au même titre que la prédiction de branchement. Toutes les techniques de ce genre exécutent des lectures dans le désordre. L'avantage est que cela permet d'exécuter les lectures en avance. Vu que la donnée lue est l'opérande d'autres instructions, les exécuter en avance fait prendre de l'avance à un paquet d'instructions. Mais il faut comparer cet avantage au risque de mauvaise prédiction. Et aussi bizarre que cela puisse paraître, cela donne de bons résultats. Il faut dire que les situations où une lecture relit une donnée tout juste écrite sont rares : même pas 2 à 3 % des lectures. Les dépendances mémoire sont donc très rares, les cas d'''aliasing'' sont vraiment limités. En termes de cout en transistors, le hardware qui vérifie les dépendances mémoire reste globalement le même à peu de choses près, il est juste déplacé dans le pipeline et adapté à sa nouvelle fonction. ===La file de lectures=== La technique que nous allons voir émet les lectures dans l'ordre, ce qui simplifie énormément la gestion des dépendances. Les lectures sont émises dans l'ordre, les écritures aussi. Par contre, l'ordre entre lectures et écritures n'est pas respecté. Une lecture peut passer avant une écriture sans problèmes, voire avant plusieurs écritures d'un seul coup. Une telle lecture exécutée en avance d'une autre écriture est appelée une '''lecture spéculative'''. Une lecture spéculative peut donc sauter devant une ou plusieurs écritures, qui seront appelées des '''écritures sautées'''. Une écriture sautée est une précédant une lecture spéculative, dans l'ordre du programme. Évidemment, il se peut qu'une écriture sautée écrive la donnée qui est lue par la lecture spéculative. Il y a alors une violation de dépendance mémoire. Mais le processeur suppose qu'il n'y a pas de dépendantes de ce type et émet la lecture en avance. Rassurez-vous, il surveille quand même la situation, pour tout annuler ensuite s'il détecte une violation de dépendance mémoire. J'ai dit plus haut que le processeur émet les lectures dans l'ordre, ainsi que les écritures dans l'ordre. Pour cela, la LSQ est scindée en deux files de µops : une pour les µops LOAD et une autre pour les µops STORE. Les cours d'architecture des ordinateurs nomment souvent ces deux files en utilisant les termes de ''file de STORE'' (''store queue'') et de ''file de LOAD'' (''load queue''), mais il y a un risque de confusion avec la file d'écriture vue précédemment, et la file de lecture qu'on introduira dans la suite. Dans ce qui suit, nous parlerons de '''file de µops LOAD''' et de '''file de µops STORE''', pour éviter toute confusion. Les deux files sont chacune connectées à l'unité mémoire, via un port dédié. L'unité mémoire a donc un port pour les lectures, et un port pour les écritures. Le port d'écriture est relié à la file d'écriture, le port de lecture est directement relié au cache de données. Il faut noter qu'une unité mémoire à deux ports n'est pas si bizarre que cela. En effet, le cache de données a lui aussi deux ports, dans la majorité des cas, avec un port de lecture et un port d'écriture. [[File:Load Ordering, Store Ordering.jpg|centre|vignette|upright=2|Exécution dans le désordre des lectures.]] Maintenant, voyons comment sont détectées les violations de dépendances mémoire. Les dépendances qui peuvent être violées dans ce cas de figure se font entre une écriture sautée et une lecture spéculative. Si une lecture spéculative lit une donnée qui est écrite par une écriture sautée, alors une dépendance RAW a été violée. Pour les détecter, le processeur garde une trace des lectures spéculatives; tant que les instructions précédentes ne sont pas terminées. Pour cela, il utilise une file d'attente, qui mémorise les lecture spéculatives. Nous l'appellerons la ''file de vérification des lectures'', ou encore la '''file de lectures'''. Elle est une sorte d'équivalent de la file d'écriture, mais pour les lectures. Les lectures sortent de la file µops LOAD dès que leurs adresses sont calculées, sans tenir compte des dépendances mémoires. Elles sont alors ajoutées à la file de lectures et s'exécutent immédiatement, de manière spéculative. Pour vérifier qu'il n'y a pas d'erreur de spéculation, le processeur conserve l'adresse de la lecture effectuée dans la file de lectures. Les lectures quittent la file de lecture quand toutes les instructions précédentes dans l'ordre du programme sont terminées, à savoir tant quand elles ont quitté le tampon de réordonnancement/ROB. Notons que la file de vérification des lectures contient les lectures dans l'ordre du programme. En effet, les lectures sont accumulées en sortant de la file de µops LOAD, où elles sont triées dans l'ordre du programme. Et cela permet de gérer les erreurs de prédiction parfaitement, en annulant les instructions suivant une erreur de prédiction. [[File:Spéculation avec une file de lecture.jpg|centre|vignette|upright=2.5|Spéculation avec une file de lecture.]] Reste à détecter les erreurs de prédiction, ce qui peut être fait avec deux méthodes. La première détecte les violations de dépendances mémoire à chaque écriture. L'idée est que chaque écriture vérifie si une lecture anticipée n'a pas lue la donnée écrite en avance. Pour cela, quand une écriture est émise, elle consulte la file de lecture pour voir s'il y a un conflit d'adresse. La vérification fait cependant gaffe à ne prendre en compte que les lectures situées après l'écriture dans l'ordre du programme. Précisément, elle envoie l'adresse à écrire à la file de lecture et cherche si une lecture ultérieure a eu lieu à la même adresse. Si c'est le cas, cela signifie qu'une lecture anticipée aurait du lire la donnée actuellement écrite, mais que la lecture a été exécutée trop en avance. La lecture n'a pas lu la bonne donnée, il y a eu une violation de dépendance mémoire. Pour faire cette vérification, la file de lecture est un mélange entre FIFO et cache/mémoire associative, sur le même modèle que la file d'écriture. La différence est que la consultation de la file de lecture ne sert pas à faire du contournement, mais à détecter les dépendances. Quand une dépendance est détectée, l'écriture se fait normalement. Par contre, la lecture marquée comme invalide dans la file de lecture. L'exécution du programme se poursuit, jusqu'à ce que la lecture arrive à l'étape de ''commit'', à savoir que le ROB lui dit que les instructions précédentes sont terminées sans encombre. Là, la lecture étant invalide, le processeur déclenche une procédure de réparation, qui vide partiellement le pipeline de toutes les instructions qui suivent la lecture. La lecture est de plus ré-executée, afin de lire la bonne donnée. Avec la seconde méthode, les violations de dépendance mémoire sont détectées au moment où la lecture quitte à la file de lectures et le ROB. L'idée est de comparer la donnée lue de manière spéculative avec la donnée lue de manière non-spéculative. Pour faire la comparaison, la lecture spéculative copie la donnée lue dans la file de lecture. Quand la lecture quitte la file de lecture et le ROB, la lecture est ré-exécutée et la donnée relue. Le processeur compare alors la donnée lue à ce moment avec la donnée mémorisée dans la file de lecture. Si la donnée est différente, alors il y a eu erreur de violation de dépendance mémoire. Évidemment, relire une donnée n'est pas gratuit. Il est possible de limiter son impact en performance en dédiant un port de lecture sur le cache rien que pour ça. Mais le cout en circuit est comparable à l'usage d'une FIFO associative de la technique précédente. Mais quoi qu'il en soit, le cache doit permettre des lectures en un seul cycle, sans quoi la méthode ne marche pas vraiment. ===La prédiction de dépendances mémoires=== Certains processeurs essayent de prédire si deux accès mémoires sont dépendants : ils incorporent une unité qui va fonctionner comme une unité de prédiction de branchement, à la différence qu'elle prédit les dépendances mémoires. Si cette unité prédit que deux accès mémoires sont indépendants, le processeur les exécute dans le désordre, et les exécute dans l'ordre du programme dans le cas contraire. Il faut prendre en compte les erreurs de prédiction, ce qui est fait comme pour les mauvaises prédictions de branchement : on vide le pipeline. Beaucoup de techniques de prédiction des dépendances mémoire ont été inventées et en faire une revue exhaustive serait long et fastidieux. On pourrait citer les ensembles colorés (''color sets''), et bien d'autres techniques. Mais nous n'allons parler que des techniques les plus élémentaires. Quoi qu’il en soit, la recherche sur le sujet est assez riche, comme toujours quand il s'agit d'architecture des ordinateurs. On peut améliorer cette technique en mémorisant les instructions qui ont causé une mauvaise prédiction de dépendance mémoire. La prochaine fois qu'on exécute ces instructions, on sait qu'il y a de grandes chances qu'il se produise une erreur de prédiction, ce qui pousse à ne pas les exécuter de manière spéculative. On peut pour cela réutiliser les techniques de prédiction de branchement, tels des compteurs à saturations, mis à jour en cas d'erreurs de prédiction de dépendances mémoire. Dans tous les cas, on trouve un cache, équivalent au ''branch target buffer'', qui mémorise les instructions fautives (leur ''Program Counter''), avec d'autres informations comme les adresses des instructions dépendantes. La première classe de techniques du genre consiste à mémoriser les lectures qui ont causé une violation de dépendance, tandis que l'autre ne mémorise que les écritures. Cette dernière est l'approche utilisée par le '''cache de barrières d’écriture''' (store barrier cache). Enfin, nous allons conclure avec une dernière technique : celle des '''ensembles d’écritures''' (store sets). Cette technique est capable de gérer le cas où une lecture dépend de plusieurs écritures : le processeur mémorise l'ensemble des écritures avec lesquelles la lecture a déjà eu une dépendance. Cet ensemble d'écritures associées à une lecture est appelé tout simplement un ensemble d’écritures, et reçoit un identifiant (un nombre) par le processeur. Cette technique demande d'utiliser deux tables : * une qui assigne un identifiant d’ensemble d’écritures à l'instruction en cours ; * une autre qui mémorise les ensembles d’écritures sous la forme d'une liste chainée : le début de la liste correspond à l'écriture la plus ancienne de l’ensemble. Cela permet d'obtenir l'écriture la plus ancienne dans cet ensemble d’écritures directement. Quand le processeur détecte une violation de dépendance entre une lecture et une écriture, elle ajoute l'écriture dans l’ensemble d’écritures adéquat. Le déroulement d'une lecture demande d'accéder à la première table pour récupérer l'identifiant de l’ensemble d’écritures, et l'utiliser pour adresser la seconde table. S'il y a dépendance, cet accès renvoie l'écriture fautive en cas de dépendance. Quand une écriture est envoyée à l'unité mémoire, celle-ci va accéder à la table de correspondances de la même manière qu'une lecture, et va récupérer l'identifiant de l’ensemble d’écritures auquel elle appartient, identifiant qui est utilisé pour vérifier s'il y a une écriture en cours d’exécution. Si c'est le cas, cela signifie que l'écriture en cours d’exécution doit s’exécuter avant l'écriture qui a consulté la table : cette dernière est mise en attente. ==La prédiction d'adresse et de valeur== D'autres techniques de prédiction mémoire plus élaborées que les précédentes existent. Il s'agit de la prédiction d'adresse et de la prédiction de valeur. Leur nom trahit ce qu'elles font. La première technique tente de prédire l'adresse d'une lecture/écriture, alors que la seconde tente carrément de prédire quelle sera la donnée lue. Il y a bien de la recherche académique sur le sujet, mais il s'agit de techniques assez osées, dont on voit mal comment elles pourraient fonctionner. L'efficacité de cette technique a été étudiée grâce à des simulateurs. Suivant les études ou les programmes, on trouve des résultats qui varient pas mal. Dans certains cas, la performance baisse un peu, et dans d'autres, on peut avoir des gains de plus de 10% ! Mais dans des cas normaux, on trouve des gains de 4-5% environ. Ce qui est tout de même pas mal à l'heure actuelle. Cependant, en Janvier 2025, à l'heure où j'écris ces lignes, il a été découvert la première utilisation de ces deux techniques dans un processeur commercial. La découverte fait suite à la publication de deux vulnérabilités matérielles sur les CPU Apple M2 et M4 et quelques CPU ARM, qui font justement usage de ces deux techniques. Les deux vulnérabilités, appelées SLAP et FLOP, sont assez similaires aux attaques Spectre et Meltdown, sauf qu'elles attaquent non pas la prédiction de branchement, mais la prédiction d'adresse et de valeur du CPU. ===La prédiction d'adresse=== La prédiction d'adresse tente de prédire l'adresse d'une lecture à l'avance, ce qui permet de lancer les lectures en avance. Si la prédiction est correcte, on gagne quelques cycles qui permet aux unités de calcul de faire leurs calculs plus en avance, sans compter que les mécanismes de désambiguïsation mémoire fonctionnent plus efficacement. Si jamais cette adresse est connue plus tôt, on détecte les dépendances plus rapidement et on peut agir en conséquence. Des variantes de ces unités de prédiction d'adresse sont utilisées pour précharger des données depuis le cache de donnée. Et leur efficacité est assez bonne, voire excellente dans certains cas. La différence est que la prédiction d'adresse est utilisée pour prédire les adresses à lire depuis le cache vers les registres, ce qui est le sujet de cette sous-partie. La prédiction d'adresse est réalisée par une sorte de cache, qui associe à chaque instruction la prochaine adresse à lire. Nous appellerons ce cache la '''table de prédiction d'adresse'''. Pour faire l'association instruction <-> adresse lue/écrite, le tag du cache utilise l'adresse de l'instruction (le ''program Counter''), alors que la ligne de cache associée contient l'adresse prédite. Son contenu est utilisé pour faire une prédiction. Lorsqu'une lecture est émise, la table est consultée pour récupérer en avance l'adresse à lire, avant que celle-ci soit calculée par l'ALU. La lecture est alors exécutée en avance, avant que la vraie adresse soit connue, en utilisant l'adresse prédite. Reste qu'il faut des circuits autour de cette table de prédiction d'adresse, pour déterminer si la prédiction est correcte, ainsi que pour remplir cette table avec une adresse prédite. Pour cela, il y a un circuit de prédiction d'adresse dédié. Il récupère les instructions qui quittent le ROB ou la file des lectures mémoire, afin de vérifier les lectures qui viennent de se terminer/''commit'' et qui quittent le pipeline. Il peut contenir une '''table d'apprentissage''', qui mémorise des informations nécessaires pour faire des prédictions d'adresse. Par exemple, un historique des dernières adresses lues pour les lectures les plus récentes, ou un historique des adresses des lectures. La table est aussi utilisée pour déterminer si les prédictions effectuées étaient correctes ou non. Pour cela, il est possible de réutiliser les techniques vues dans le chapitre sur la prédiction de branchement, notamment des compteurs à saturation. Pour la prédiction de branchement et les autres formes de prédiction mémoire, les table d'apprentissage et pour les instructions/adresses sont fusionnées en une seule. Mais ici, il est nécessaire de les séparer pour une raison assez particulière : éliminer la pollution du cache dans la table de prédiction d'adresse. On ne peut pas allouer une ligne de cache pour chaque nouvelle instruction de lecture exécutée. Seule une minorité des lectures profitent de la prédiction d'adresse. Il est nécessaire de filtrer les lectures pour ne conserver que celles qui profitent de la prédiction d'adresse, ce qui est possible plus facilement en séparant la table d'apprentissage et les compteurs à saturation de la table de prédiction d'adresse. Une première méthode consiste à prédire que la lecture lit toujours la même adresse, ce qui lui vaut le nom de '''prédiction d'adresse constante'''. On pourrait se demander pourquoi une telle situation surviendrait. La raison est qu'il arrive que les compilateurs n'arrivent pas à gérer les accès mémoires efficacement. Dans certaines situations un peu compliquées impliquant des pointeurs ou des références, divers phénomènes complexes d'''aliasing'' des pointeurs peuvent générer des relectures intempestives de données en mémoire. Cela peut aussi arriver lorsqu'on compile du code qui provient de plusieurs librairies, ou quand du code lit régulièrement des constantes en mémoire ROM. La prédiction d'adresse constante ajoute un circuit qui analyse les dernières lectures, une fois qu'elles quittent le ROB, qu'elles ''commit''. Si, pour une instruction de lecture, l'adresse lue est toujours la même, alors l'instruction est ajoutée dans la table de prédiction d'adresse. Vu que les instructions qui accèdent toujours à la même adresse sont rares, il est préférable d'initialiser les compteurs à saturation de façon à ce qu'ils disent que toute nouvelle instruction change d'adresse. Il s'agit d'une méthode simple mais peu efficace, car cette situation est rare. Une autre méthode détecte les accès en enjambées, là encore quand les lectures quittent le ROB. Nous l’appellerons la '''prédiction d'adresse en enjambées'''. De tels accès surviennent régulièrement quand on accède des tableaux ou d'autres structures de données semblables, ce qui rend la technique très intéressante. Implémenter cette technique est facile : il suffit d'ajouter un circuit qui détecte les accès en enjambées, au lieu de celui qui détecte les accès constants. À chaque fois qu'une lecture entraine un succès de cache dans la table de prédiction d'adresse, l'enjambée est ajoutée à l'adresse contenue dans la table de prédiction d'adresse, l'ancienne adresse prédite est remplacée par celle avec l'enjambée d'ajoutée. Sur les Apple M2-M4, la prédiction d'adresse gére aussi bien les adresses constantes que les accès en enjambée. Le brevet qui décrit l'implémentation de l'unité de prédiction d'adresse et de valeur est potentiellement le suivant : [https://patents.google.com/patent/US11829763B2/en Early load execution via constant address and stride prediction]. En théorie, il est possible de faire mieux en repérant des accès mémoires qui se répètent de façon régulière et cyclique, même s'ils n'ont aucune enjambée. Ce genre d'accès se trouve assez souvent lorsque l'on manipule des listes chainées ou des structures de données assez irrégulières comme des arbres, des graphes, etc. Mais il n'y a pas encore de CPU qui implémente de telle optimisation. ===La prédiction de valeur : prédire la donnée lue=== Les techniques de '''prédiction de valeur''' (''Value Prediction'') consistent à prédire quelle est la donnée qui sera lue par une instruction de lecture. Oui, vous avez bien lu : le processeur est capable de parier sur la valeur qui sera chargée depuis la mémoire, de prédire si cette valeur vaut 0, 1, 1024, etc. Une fois son pari fait, il exécute les instructions du programme avec la valeur qu'il a pariée de façon spéculative. Si le pari est correct, le CPU continue l’exécution. Sinon, il est obligé de vider le pipeline et de recommencer avec la bonne valeur. Au premier abord, cette technique semble franchement mal partie tellement les chances de se tromper sont tellement énormes ! Mais le fait est que, dans certains cas, il est possible de spéculer correctement. Là encore, les techniques les plus simples fonctionnent dans deux cas. Le premier est celui où une donnée est relue à l'identique à chaque fois que la lecture est exécutée. Le second est celui où la donnée est incrémentée/décrémentée d'une constante qui est toujours la même d'une exécution de la lecture à l'autre. Une sorte d'enjambée constante, mais pour les données. Nous allons nous concentrer sur le premier cas, celui où une donnée est relue plusieurs fois sans être modifiée entre temps. L'idée de base est qu'une instruction de lecture qui s'exécute plusieurs fois de suite à la même adresse renvoie la même valeur à chaque fois. C'est parfaitement possible s’il n'y a eu aucune écriture à cette adresse entre-temps, d'où le fait qu'il faille surveiller si les prédictions constantes sont correctes. On peut se demander quelles sont les raisons qui font qu'une instruction de lecture renvoie la même valeur à chaque fois. Après tout, autant lire une seule fois la donnée et la garder dans un registre une bonne fois pour toutes ! Mais dans la réalité, on fait face à quelques limites, qui seront détaillées dans la liste suivante. La première est que le manque de registre fait que certaines données sont conservées en RAM et sont relues fréquemment. Cela arrive quand on stocke des constantes en mémoire. Par exemple, sur les processeurs x86, les constantes flottantes ne peuvent pas être intégrées dans nos instructions via le mode d'adressage immédiat. À la place, on les stocke en mémoire et on les charge dans les registres à chaque fois qu'on en a besoin. L'implémentation est similaire à la prédiction d'adresse, si ce n'est que c'est la donnée lue qui est prédite. Là encore, on a une '''table de prédiction de donnée lue''', consultée lorsqu'une lecture est émise. La table de prédiction de la donnée lue est une mémoire cache qui associe le ''Program Counter'' de la lecture à la donnée prédite, éventuellement couplé à des compteurs à saturation. Elle est consultée à chaque fois qu'une lecture est émise, la donnée adéquate est récupérée lors d'un succès de cache. On trouve aussi un circuit qui insère ou retire des instructions de la table de prédiction de donnée lue. Elle détecte les lectures prédictibles, couplé à une table d’apprentissage, et quelques circuits annexes liés au ROB ou aux files de lectures. Elle contient au minimum des compteurs à saturations, associés chacun à une instruction (son ''program counter'' pour être précis). ==L'optimisation du ''Mirroring memory operand''== Les processeurs modernes implémentent une optimisation concernant les accès mémoire. Cette optimisation a été décrite par Agner Fog dans ses manuels d'optimisation, sous le terme de '''''Mirroring memory operand'''''. Il a documenté son apparition sur les processeurs Zen 2 d'AMD, puis son évolution sur les processeurs Zen 4 et 5. L'optimisation est bizarrement absente de la microarchitecture Zen 3. Elle se manifeste dans une condition très précise : plusieurs instructions proches accèdent à la même adresse. Les deux instructions ne sont pas forcément consécutives, il peut y avoir une dizaine ou centaine d'instructions entre les deux. Une telle situation survient souvent sur les CPU CISC, et notamment sur les CPU x86, car ils ont peu de registres. les compilateurs font donc beaucoup d'échanges entre pile d'appel et registres, ce qui fait que des données écrites dans la pile sont souvent relue quelques instructions plus tard. Dans ce cas, la donnée lue/écrite est mémorisée dans un registre interne au processeur et peut être accédée très rapidement. Par exemple, imaginons plusieurs lectures proches à la même adresse, sans écriture entre elles. Dans ce cas, le processeur ne fera qu'une seule lecture en mémoire RAM, les lectures ultérieures liront la donnée depuis le registre interne. Comme autre exemple, imaginons une écriture suivie plus tard par une lecture à la même adresse. Dans ce cas, la donnée sera écrite dans un registre interne, en plus d'être propagée dans le cache ou la RAM, et la lecture ultérieure lira directement le registre interne. Le gain en performance est alors conséquent, comparé à du simple ''store-to-load forwarding''. Il faut noter que la technique marche aussi pour les opérations ''load-op'', pas seulement pour les instructions d'accès mémoire. Par exemple, prenons une écriture en RAM, suivies quelques instructions plus tard par une opération ''load-op'' à la même adresse. L'écriture écrit à une adresse, qui est lue par l'instruction ''load-op''. L'optimisation s'applique : l'instruction ''load-op'' lira l'opérande mémoire depuis un registre interne du processeur, qui contient une copie de la donnée écrite. Sur les processeurs Zen 2, l'optimisation marchait aussi pour les instructions PUSH et POP, qui lisaient/écrivaient dans la pile d'appel. Mais cette possibilité a été retirée sur les processeurs Zen 5. La technique détecte que deux instructions accèdent à la même adresse sous certaines conditions bien précises. L'optimisation ne marche que pour les modes d'adressages indicés et indirect, avec ou sans décalages, mais pas en mode absolu ou relatifs. Il faut préciser que l'optimisation ne marche que pour les registres généraux, pas les registres flottants. Les registres doivent être précisés de la même manière dans toutes les instructions. Par exemple, le processeur ne reconnait pas que l'addition des deux registres RCX + RAX est la même que RAX + RCX. Sur les CPU Zen 2 et 4, elle ne marchait que pour des opérandes de 32 et 64 bits, mais les processeurs Zen 5 gèrent des données de 8 et 16 bits aussi. Les décalages étaient limités à 8 bits sur le Zen 2, mais n'a plus de limites sur les Zen 4 et 5. ==La désambiguïsation mémoire a des effets de bord== La désambiguïsation mémoire a une caractéristique que l'exécution dans le désordre simple n'a pas : elle a des effets qui débordent sur la mémoire. Il est possible de savoir si un processeur fait de la désambiguïsation mémoire en regardant le bus mémoire. On s’aperçoit alors que les lectures se font dans un ordre différent de celui du programme. À l'opposé, l'exécution dans le désordre n'a pas d'effets observables de ce genre. Elle améliore la performance, elle modifie les durées observables des instructions, mais cela se limite à des questions de ''timings''. Le seul moyen de savoir si un processeur fait de l'exécution dans le désordre est de mesurer les latences/durées de chaque instruction. Mais aucun état observable au-delà des ''timings'' n'est modifié. Pas avec la désambiguïsation mémoire. ===La consistance mémoire=== Le fait que la désambiguïsation mémoire ait des effets observables ne pose aucun problème avec un seul processeur, ca elle est conçue pour. Par contre, les techniques de désambiguïsation mémoire peuvent poser des problèmes avec plusieurs processeurs. Il arrive que des programmes partagent une même portion de mémoire, et lisent/écrivent dedans. Avec plusieurs processeurs, chaque processeur effectue la désambiguïsation mémoire dans son coin sans se préoccuper des autres. Les opérations d'écriture peuvent être mises dans le désordre du point de vue des autres processeurs, et les lectures effectuées par les autres processeurs peuvent alors renvoyer de vielles données. Un exemple classique est celui de deux cœurs qui partagent le même cache ou la même mémoire, mais qui utilisent chacun une file d'écriture (''store buffer''). Imaginons que le premier cœur effectue une écriture, puis une lecture, à des adresses différentes. L'écriture est retardée grâce au tampon d'écriture, alors que la lecture lit directement le cache. Pour le premier processeur, l'écriture s'est réalisée avant la lecture, car pour lui, la fin de l'écriture signifie écrire dans le tampon d'écriture. Mais pour le second cœur, l'ordre s'est inversé : il voit la lecture dans le cache, puis l'écriture dans le cache. La raison est qu'il n'a pas connaissance du contenu du tampon d'écriture de l'autre cœur. Un autre exemple : les techniques de prédiction d'adresse et de valeur posent des problèmes avec les dépendances RAW. Elles permettent à une instruction mémoire de s'exécuter avant une autre, malgré la présence d'une dépendance RAW entre les deux. L'ordre entre les deux instructions change, alors que les autres processeurs ne devraient pas le voir. Et dans les faits, les processeurs qui incorporent ces techniques ont des modèles de consistance mémoire qui ne permettent pas ces optimisations en cas de dépendances RAW. Les deux techniques doivent idéalement être désactivées pour les instructions mémoire qui ont des dépendances RAW. Les programmeurs doivent tenir compte de ce genre de situations, dans des cas assez rares et liés à la programmation bas niveau. Pour cela, ils doivent savoir comment le processeur réorganise les accès mémoire. Concrètement, chaque processeur définit un '''modèle de consistance mémoire''' qui indique quelles optimisations sont activées et non. Il s'agit d'une sorte de contrat entre le logiciel et le matériel : les programmeurs savent comment le processeur va réorganiser les lectures et écritures et peuvent coder leurs programmes en fonction. L'implémentation du système de consistance mémoire dépend grandement de l'architecture, mais des modèles ont été standardisés afin de permettre aux programmeurs de savoir où placer les barrières mémoires. Il s'agit de vrais standards, formalisés, parfois mathématiquement, que des chercheurs étudient sur un plan mathématique et algorithmique. Le modèle préféré des programmeurs est la '''consistance séquentielle''', où les différents processeurs effectuent des accès mémoire dans l'ordre du programme et les accès mémoire simultanés sont interdits. La première contrainte interdit l'usage de mécanismes de désambiguïsation mémoire, la seconde interdit l'usage de caches non-bloquants. La consistance séquentielle a donc un cout en performance assez important, ce qui fait que les processeurs modernes n'utilisent pas la consistance séquentielle. Un modèle plus flexible est le '''''total store ordering''''', qui autorise la présence d'une file d'écriture et l'usage de caches non-bloquants pour les lectures (interdit d'avoir plusieurs écritures simultanées). C'est plus ou moins celui utilisé par les processeurs x86, comme on le verra plus bas. Il existe des modèles de consistance mémoire plus relâchés, appelés des '''modèles de consistance faible''', qui autorisent diverses optimisations de spéculation mémoire, la combinaison d'écriture (fusionner deux écritures à la même adresse en une seule), etc. Ils sont supportés sur les architectures ARM et POWER. ===Les barrières mémoire=== Le changement d'ordre des lectures et écritures peut poser occasionnellement des problèmes avec la programmation pour les architectures multi-processeur ou multicœurs. Il existe des morceaux de code qui ne marchent que si la consistance séquentielle est respectée et il faut faire en sorte qu'ils marchent sur des processeurs avec un modèle de consistance mémoire différent. Pour cela, les processeurs incorporent des instructions appelées '''barrières mémoires''' ou '''''Fences'''''. Elles forcent le processeur à terminer tous les accès mémoire qui précédent dans l'ordre du programme. Certains processeurs possèdent une barrière mémoire pour les lectures et une autre pour les écritures. D'autres processeurs ajoutent des barrières mémoires dédiées à des cas particuliers. Elles permettent de forcer les accès à des données partagées à dérouler correctement. [[File:Fences.png|centre|vignette|upright=2|Fences.]] Leur implémentation est assez simple : elles empêchent l'émission de toute nouvelle instruction mémoire, tant qu'une condition précise n'est pas réunie. Par exemple, la barrière mémoire pour les écritures attend que la file d'écriture soit vidée avant de démarrer le moindre accès mémoire. Un compilateur peut placer les barrières mémoires au bon endroit, avec un peu d'aide du programmeur. Certains langages de programmation permettent d'indiquer au compilateur qu'une donnée doit toujours être lue depuis la mémoire RAM, via le mot-clé volatile. C'est très utile pour préciser que cette donnée est potentiellement partagée par plusieurs processeurs ou manipulable par des périphériques. Les compilateurs peuvent placer des barrières mémoires lors des lectures ou écritures sur ces variables, mais ce n'est pas une obligation. Il arrive aussi que le programmeur doive manipuler explicitement des barrières mémoires. Utiliser l'assembleur est alors une possibilité, mais qui est rarement exploitée, pour des raisons de portabilité. Pour limiter la casse, certains systèmes d'exploitations ou compilateurs peuvent aussi fournir des barrières mémoires explicites, encapsulées dans des bibliothèques ou cachées dans certaines fonctions. ===La modèle de consistance des processeurs x86=== Après avoir vu la théorie, passons maintenant à la pratique. Dans cette partie, on va voir les modèles de consistances utilisés sur les processeurs x86, ceux qu'on retrouve dans les PC actuels. Le modèle de consistance des processeurs x86 a varié au cours de l'existence de l'architecture : un vulgaire 486DX n'a pas le même modèle de consistance qu'un Core 2 duo, par exemple. Ils ont évolués au cours du temps, avec l'arrivée de nouvelles optimisations. De plus, leur formalisation a été progressive, et s'est faite au fur et à mesure. En général, les modèles de consistance des processeurs x86 ont toujours étés assez restrictifs, si on les compare aux autres processeurs. Il s'agit de modèles de type ''total store ordering'' (TSO), au moins dans les grandes lignes. Ils permettent donc d'effectuer certaines lectures en avance, soit parce que les lectures se font à des adresses différentes, soit par utilisation de réacheminement écriture vers lecture. Les possibilités d'exécution en avance des lectures varient suivant le processeur, elles deviennent de plus en plus performantes avec le temps. Le premier modèle de consistance est apparu sur les premiers processeurs x86 et est resté en place sur tous les processeurs de marque Pentium. Les processeurs de l'époque n'incorporaient pas beaucoup d'optimisations, le budget en transistor n'était pas assez conséquent pour ça, la mémoire n'était pas assez lente. Le modèle TSO de l'époque n'autorisaient que peu de lectures anticipées. Une lecture ne peut être exécutée en avance que sous les conditions suivantes : * les écritures contournées se font dans la mémoire cache ; * la lecture doit se faire dans la mémoire RAM ; * les écritures se font à une adresse différente de la lecture ; * aucune transaction avec un périphérique ne doit être en cours. A partir du Pentium 4, les choses changent. Le Pentium 4 est en effet le premier processeur à implémenter des techniques permettant d’exécuter plusieurs processus en parallèle, dont l'Hyperthreading. En conséquence, le modèle de consistance a dû être assoupli pour éviter de perdre bêtement en performance. Il s'agit d'un vrai modèle de type TSO, la majeure partie des contraintes sur les lectures anticipées sont réduites au minimum. Il y a aussi des contraintes supplémentaires pour les instructions dites atomiques, ainsi que pour les instructions qui accèdent aux périphériques ou aux entrées-sorties. Une optimisation séparée du TSO est que les écritures en mémoire peuvent changer d'ordre dans un cas exceptionnel : les écritures effectuées par les instructions de copie mémoire comme REPMOVSD, REPSCACB, ainsi que les instructions SSE MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, MOVNTPD. Les écritures effectuées dans ces instructions peuvent se faire dans un désordre complet (ou presque). Lors du passage au 64 bits, le modèle mémoire des processeurs x86 de l'époque a été formalisé dans un papier d'Intel appelé [https://www.cs.cmu.edu/~410-f10/doc/Intel_Reordering_318147.pdf Intel® 64 Architecture Memory Ordering White Paper], mis à jour en 2008 suite à quelques problèmes techniques. La formalisation du modèle a été d'une grande aide aux programmeurs, qui devaient se débrouiller avec des documentations qui ne formalisaient pas de modèle de consistance précis. Le document décrit un modèle de consistance mémoire portant le nom barbare de ''total lock order + causal consistency” (TLO+CC)'', qui admet plus d'optimisations que le modèle TSO. Mais la description était en réalité légèrement différente du modèle mémoire réellement implémenté sur les processeurs existants, qui utilisaient du TSO, sauf en de rares occasions. Les concepteurs de processeur font souvent cela, à savoir définir un modèle de consistance plus laxiste que réellement implémenté, pour des questions de comptabilité. Ainsi, si les futurs processeurs de la marque implémentent un modèle plus laxiste, les programmes déjà codés continuent de fonctionner. Le modèle décrit dans la documentation laisse de la marge pour le futur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le parallélisme mémoire | nextText=Le parallélisme mémoire }} </noinclude> j9t458dvdul59vzgmidaecc535arm1o 774174 774139 2026-10-04T19:42:07Z Mewtow 31375 /* Les schedulers mémoire unifiés */ 774174 wikitext text/x-wiki Dans les chapitres précédents, nous avons vu la mal-nommée exécution dans le désordre dans les chapitres précédents, qui devrait plutôt s'appeler l'émission dans le désordre. Elle modifie l'ordre des instructions pour gagner en efficacité, mais cela n'est valable que pour les instructions travaillant sur des registres ! Nous n'avons pas parlé de l'exécution dans le désordre des accès mémoire, car ils sont un peu à part. Et pour comprendre pourquoi, nous allons dédier ce chapitre à la gestion des accès mémoire dans le pipeline. Avant de poursuivre, faisons un rappel rapide sur les dépendances de données. Il faut distinguer deux types de dépendances de données : les dépendances de registres (deux instructions manipulent le même registre), et les dépendances d'adresse (deux instructions manipulent la même adresse). Deux instructions ont une dépendance d'adresse si elles écrivent ou lisent la même adresse mémoire. Des instructions mémoires qui lisent/écrivent des adresses différentes sont indépendantes. Lorsque deux instructions mémoire lisent/écrivent à la même adresse, on parle d''''aliasing mémoire'''. Formellement, l'aliasing mémoire n'est ni plus moins que l'application des dépendances de données aux instructions mémoire. Et comme les autres dépendances de données, il existe plusieurs types d'aliasing mémoire : RAR, RAW, WAR et WAW. Les dépendances RAR ne posent aucun problème, les lectures peuvent changer d'ordre sans aucun problème, tant qu'il n'y a pas d'écritures entre les deux. Par contre, les autres dépendances imposent trois contraintes. * Les dépendances RAW imposent que les lectures ne doivent pas s'exécuter avant une écriture à la même adresse. * Les dépendances WAR imposent que l'on ne peut pas déplacer une écriture avant une lecture à la même adresse. * Enfin, les dépendances WAW font que l'ordre des écritures doit être celui du programme : impossible de changer l'ordre de deux écritures. Les trois contraintes peuvent s'implémenter de plusieurs manières différentes. Comme pour les dépendances de données normales, seules les dépendances RAW sont de vraies dépendances de données. Les deux autres dépendances viennent du fait que l'on utilise la même adresse pour stocker des valeurs différentes. Détecter les cas d'''aliasing'' mémoire est primordial pour gérer la désambiguïsation mémoire, et cela demande de comparer des adresses. S'il existe des techniques similaires au renommage de registre permettent de les faire disparaitre, elles sont très limitées. Diverses optimisations permettent d'émettre des accès mémoire dans le désordre, exactement comme l’exécution dans le désordre le fait pour les autres instructions. Elles utilisent pour cela des ''schedulers'' spécialisées dans les accès mémoire. Il faut vraiment insister sur le fait que l'exécution dans le désordre proprement dite ne se préoccupe pas des micro-opérations mémoire. Pour faire la distinction, on parle de '''désambiguïsation mémoire''' (''memory disambiguation'') pour parler de l'exécution dans le désordre des accès mémoire. Le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. La raison est que la donnée lue est utilisée par d'autres instructions, dépendantes de la lecture. Elles sont donc à la tête d'une chaine de dépendances de données qui peut bloquer le pipeline si elle n'est pas résolue très tôt. Dès qu'une lecture peut être lancée, elle accède directement au cache de données. Par contre, si l'''aliasing'' ne le permet pas (écriture à la même adresse pas encore effectuée) ou que l'adresse à lire n'a pas encore été calculée, la lecture attend son tour. Le problème est que la désambiguïsation mémoire demande de comparer des adresses, pour détecter les dépendances d'adresse. Et cela pose de sérieuses contraintes pour son implémentation. L'exécution dans le désordre normale compare des registres, ce qui est beaucoup plus simple. Les registres utilisés sont directement encodés dans l'instruction elle-même, ce qui fait qu'on peut détecter les dépendances de registre après le décodage. Et elles peuvent même être éliminées par le renommage de registre pour les dépendances WAW et WAR. Par contre, les adresses ne sont pas encodées dans l'instruction elle-même (sauf avec le cas particulier de l'adressage absolu), mais sont lues depuis les registres ou calculées par une unité de calcul. Les adresses ne sont donc connues qu'une fois que l'instruction a attendu suffisamment de temps dans le ''scheduler''. ==L’émission dans l'ordre des accès mémoire== Avant toute chose, faisons quelques rappels sur l'exécution dans le désordre "normale", sans désambiguïsation mémoire. Les micro-opérations renommées sont mises en attente dans un ''scheduler'', le temps que leurs opérandes soient disponibles. Les µops mémoire font cependant exception. ===La file de µops mémoire=== Avec l''''émission dans l'ordre des accès mémoire''', le processeur émet les accès mémoire dans l'ordre du programme. Cela implique que l'unité mémoire n'a pas de ''scheduler'', ni de station de réservation associée. À la place, les micro-opérations mémoire sont placées dans une file de micro-opération dédiée, appelée la '''file de µops mémoire'''. La file de µops mémoire est une mémoire FIFO, dont chaque entrée peut accueillir une µops mémoire. [[File:Unité de gestion des accès mémoires dans l’ordre.png|centre|vignette|upright=2.0|Unité de gestion des accès mémoires dans l’ordre]] Par contre, le processeur utilise des ''schedulers'' pour les unités de calcul. Autant les accès mémoire se font dans l'ordre, autant les instructions arithmétiques/logiques/branchements/autres sont exécutées dans le désordre. En clair, le processeur supporte l'exécution dans le désordre, sauf pour les micro-opérations mémoire. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] Vu de l'extérieur, il faut attendre qu'un accès mémoire soit terminé pour lancer le suivant. Avant d'émettre une micro-opération mémoire, la file de micro-opération (son ''scoreboard'') vérifie si l'unité d'accès mémoire est libre ou non. Si elle est occupée, un accès mémoire est en cours, et on ne peut pas émettre de micro-opération mémoire. Une fois l'accès mémoire terminé, la mémoire ou le cache envoient un signal pour indiquer que l'accès est fini, et c'est au tour d'une nouvelle micro-opération mémoire. L'unité mémoire a un port sur lequel on envoie la micro-opération émise, que ce soit une écriture ou une lecture. Une file de µops implique la ''lecture après émission'', à savoir que les µops mémoire quittent la file de µops, avant de lire les registres adéquats. Il y a donc une connexion entre banc de registre et unité mémoire, comme sur n'importe quel autre processeur. Mais la file de µops n'est pas connectée au banc de registre, ni au réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] L'organisation précédente laisse penser que les calculs d'adresse sont forcément réalisés dans l'unité mémoire. Mais ce n'est pas une obligation. Il est parfaitement possible de scinder une µops mémoire en deux : une µop mémoire proprement dite et une µop de calcul d'adresse. La liaison entre les deux doit cependant se faire en utilisant un registre. L'adresse calculée est enregistrée dans un registre d'adresse "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. ===La file d'écriture : une FIFO de mise en attente des écritures=== L'unité mémoire exécute une µops mémoire à la fois. Avec un détail particulier : les écritures doivent attendre que toutes les instructions se terminent, avant de pouvoir s'exécuter. Sans ça, les exceptions précises sont impossibles, la prédiction de branchement l'est aussi et l'exécution dans le désordre aurait des conséquences fâcheuses (en exécutant des écritures en avance). Précisément, les écritures doivent respecter deux contraintes : s'exécuter dans le tout dernier étage du pipeline, s'exécuter dans l'ordre du programme. Il serait possible de bloquer l'émission des écritures tant que le ROB ne donne pas son accord. Mais ce n'est pas du tout ce qui est fait. A la place, les écritures sont mises en attente dans une mémoire FIFO, appelée la '''file d'écriture''', ou encore la ''Write Queue''. Les écritures sont mises en attente, jusqu'à ce que le ROB leur donne le okay pour transfer l'écriture en mémoire RAM. Nous en avions déjà parlé dans le chapitre sur le pipeline, dans la section sur les exceptions précises. Mais quelques rappels ne feront pas de mal. L'écriture est insérée dans la file d'écriture lorsqu'elle est émise, elle en sort à l'étage de ''Writeback'' si tout se passe bien. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente, faites à tort. De plus, la file d'écriture est une mémoire FIFO, qui mémorise les écritures dans l'ordre du programme. En effet, les écritures sont ajoutées dans la file d'écriture lorsqu'elles sont émises, et elles sont émises dans l'ordre du programme. [[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Write Queue sur un pipeline fixe]] La file d'écriture garantit que les écritures sont dans l'ordre du programme. Et cette propriété élimine totalement les dépendances WAW. Le fait que les écritures se font à la fin du pipeline élimine quant à elle les dépendances WAR. Il ne reste plus qu'à gérer les dépendances RAW, celles où une lecture lit une donnée écrite par une autre instruction. Une telle situation survient, car les écritures sont mises en attente pendant un certain temps, la donnée écrite peut être lue pendant ce laps de temps. La lecture ne peut pas lire la donnée dans le cache, car la donnée à lire n'y est pas encore enregistrée. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire, mais il existe. Il se manifeste surtout sur les processeurs avec peu de registre, où des données doivent régulièrement être déplacées temporairement en mémoire RAM. Pour éviter cela, une solution consiste mettre en attente les lectures si une écriture est en attente. Les lectures sont maintenues dans la file de µops tant que la file d'écriture n'est pas vide. La file de µops mémoire est suivie d'un circuit de détection des dépendances, un '''''scoreboard'' mémoire''' qui est assez simple. L'unité mémoire fournit un bit qui indique si la file d'écriture est vide ou non, ainsi qu'un second bit qui indique qu'elle est vide. Les deux ne sont pas compliqués à générer, ils font partie intégrante de l'interface de toute mémoire FIFO digne de ce nom. Le ''scoreboard'' mémoire utilise ces deux bits pour savoir s'il peut émettre une micro-opération mémoire. Il n'émet pas de lecture tant que la file d'écriture n'est pas vide. Il peut par contre émettre des écritures tant qu'elle n'est pas pleine. Une meilleure solution serait d'interdire les lectures, mais seulement en cas de dépendance mémoire détectée. Et c'est là qu'intervient la technique du '''''load bypassing'''''. Elle autorise une lecture, si elle n'a aucune dépendance avec les écritures mises en attente dans la file d'écriture. Elle doit être mise en attente dans le cas contraire. Pour l'implémenter, il faut comparer l'adresse à lire avec toutes les adresses dans la file d'écriture. La lecture est exécutée immédiatement si aucune correspondance n'est trouvée, mais elle est mise en attente dans le cas contraire. Pour cela, la file d'écriture est modifiée et devient un mélange entre FIFO et mémoire associative. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'' : la lecture doit être bloquée. Sinon, c'est un défaut de ''Write Queue'', la lecture accède au cache, là où se trouve la donnée à lire. Dans le chapitre sur le contournement, nous avons parlé d'une forme de contournement qui corrige ce problème. Les lectures peuvent lire la donnée depuis la file d'écriture si besoin, ce qui renvoie la donnée adéquate. Pour cela, les lectures consultent à la fois le cache et la file d'écriture, comme dit plus haut. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'', ou encore de '''réacheminement écriture-vers-lecture'''. [[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]] La lecture est mise en attente dans l'unité mémoire, vu que c'est elle qui fait les comparaisons d'adresse. Du point de vue extérieur, le ''scoreboard'' mémoire ne voit pas s’il y a eu succès dans la file d'écriture ou non. Il voit juste que l'unité mémoire est occupée et ne peut pas accepter de nouvelle lecture. Elle reste occupée durant quelques cycles, en cas de défaut de ''Write Queue'', le temps de faire la lecture dans le cache. Par contre, en cas de succès de ''Write Queue'', elle reste occupée tant que la lecture n'a pas donné de résultat, ce qui inclue le temps de mise en attente. Les processeurs haute performance implémentent tous le ''Store to load Forwarding'', mais quelques processeurs basse performance ne le font pas. Ils se contentent du ''load bypassing'', qui offre des performances correctes pour un cout en matériel bien plus modeste. Pour donner un exemple, la micro-architecture Jaguar d'AMD était dans ce cas. Elle a été utilisée sur les consoles de jeu de 8ème génération, comme la Xbox One et la Playstation 4. Les processeurs haute performance préférent utiliser plus de circuits pour implémenter le ''Store to load Forwarding'', mais l'implémentation n'est souvent pas complète. En effet, l'implémentation est souvent simple et ne prend en compte que des lectures/écritures à des adresses identiques, sans compter que la taille des données doit aussi être identique. Une écriture d'un entier de 32 bit sera contournée et envoyée à une lecture de 32 bits à la même adresse. Mais la lecture lit seulement 16 bits sur les 32 écrits, le contournement peut ne pas marcher. Concrètement, ce cas précis fonctionnera. Mais le cas inverse, avec une lecture lisant plusieurs écritures de taille plus petite, ne marchera pas sur tous les processeurs. Ne parlons pas du cas où les données écrites sont à cheval sur deux lignes de cache. ==L'exécution anticipée des calculs d'adresse== L'exécution dans le désordre fonctionne mieux si les lectures sont exécutées le plus tôt possible. Les lectures lisent des données qui sont utilisées par d'autres instructions. Si la lecture tarde, les autres instructions sont mises en attente. Exécuter les lectures le plus tôt possible est donc crucial. Mais comment faire ? Un moyen pour ça est de faire en sorte que les adresses à lire/écrire soient connues le plus tôt possible. Mais cela implique que les calculs d'adresse ne sont pas fait en même temps que l'accès mémoire. Pour cela, il est possible de séparer les µops mémoire en deux micro-instructions : une pour le calcul d'adresse et l'autre pour les accès mémoire. On parle de '''calcul anticipé des adresses'''. Avant de poursuivre, un minimum de terminologie. Nous parlerons d'AGU pour désigner les unités de calcul d'adresse, ce qui est une abréviation de ''Adresse Generation Unit''. Nous parlerons aussi de '''µops AGU''' pour parler des µops de calcul d'adresse. Le terme est de moi, il est assez parlant : "µops exécutés dans les AGUs". Nous utiliserons ce terme pour les séparer des µops entières classiques, comme les additions/soustractions/multiplications arithmétiques, les décalages/rotations, etc. Le calcul anticipé des adresses est utilisé avec ou sans désambiguïsation mémoire. Elle a des avantages avec et sans. Sans, elle calcule les adresses en avance, ce qui fait que des lectures/écritures mises en attente ont leurs opérandes prêtes plus rapidement. Avec, cela permet de connaitre les adresses en avance, ce qui permet de détecter plus rapidement les dépendances d'adresse. Mais laissons cela pour plus tard. ===La ''Load/Store Queue'' : une file de µops améliorée=== Implémenter cette technique demande de faire deux choses. Premièrement, il faut faire les calculs d'adresse en-dehors de l'unité mémoire, soit dans une ALU entière, soit dans une AGU dédiée. Deuxièmement, les adresses calculées doivent être mises en attente quelque part, et être liées facilement à la µop mémoire associée. Pour le second point, la file de µops mémoire parait être un bon candidat, mais n'est pas exactement la solution. Une file de µops au sens strict ne mémorise pas les opérandes des µops, donc pas d'adresse. Cela implique la lecture après émission, ce qui veut dire que l'adresse à lire/écrire est lue depuis les registres une fois que la µops sort de la file de µops (idem pour la donnée à écrire). En pratique, cette implémentation rend difficile de faire les calculs d'adresse ailleurs que dans l'unité mémoire. Heureusement, il est possible d'adapter la file de µops pour gérer la lecture avant émission. L'idée est qu'elle mémorise, pour chaque µop mémoire, l'adresse à lire/écrire et d'autres informations. Il ne s'agit donc plus vraiment d'une file de µops mémoire mais quelque chose d'autre, une sorte d'intermédiaire entre une station de réservation et une file de µops. D'un côté, elle fonctionne comme une file de µops car elle émet les µops dans l'ordre du programme. De l'autre, elle mémorise les opérandes des µops mémoire, ce qui en fait une pseudo-station de réservation. Une telle structure est appelée une '''''Load/Store Queue''''' (LSQ). Une LSQ contient plusieurs entrées, chaque entrée pouvant accueillir une µops mémoire et ses opérandes. Une entrée contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures. Les deux derniers champs sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. Elle contient aussi des bits de validité, qui indique si l'adresse ou la donnée sont disponibles, ou si le champ associé est vide. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2|Load Store Queue unifiée]] La LSQ doit faire le lien entre une adresse calculée par l'AGU, et l'entrée associée de la LSQ. Pour cela, l'unité d'émission associe un '''identifiant LSQ''' à une µops mémoire. L'identifiant est généré à l'émission. Aussi bien la µops de calcul d'adresse que a µop mémoire ont toutes deux le même identifiant. L'identifiant est propagé avec la µop de calcul d'adresse, comme tout signal de commande. En sortant de l'AGU, l’identifiant est transmis à la LSQ en même temps que l'adresse. La LSQ regarde quelle entrée a cet identifiant, et mémorise l'adresse dedans. La gestion des données à écrire se fait elle avec le système de contournement normal. La LSQ sait dans quel registre se trouve la donnée à écrire, elle surveille juste le système de contournement pour détecter quand la donnée est disponible. ===Le contournement des adresses mémoire avec une LSQ=== Avec une LSQ, les registres sont lus quand la µop mémoire est insérée dans la LSQ, si elles sont disponibles. Si elles ne sont pas encore disponibles, elles seront envoyées par le système de contournement plus tard. Cela implique qu'il y ait un système de contournement, qui relie les ALUs entières à la LSQ. Les ALUs envoient leur résultat à la LSQ, qui place alors l'adresse calculée dans la bonne entrée, la bonne "µop". [[File:Load-Store Queue.png|centre|vignette|upright=2|Load-Store Queue]] Une autre solution utilise des AGU spécialisées. L'avantage est que cela simplifie grandement le réseau de contournement : seules les AGU sont connectées à la LSQ. Un autre avantage est lié à l'adressage de la destination. En utilisant des ALU entières, il faut préciser que le résultat est à destination de la LSQ, pour configurer le réseau de contournement. Avec des AGU,, l'adresse calculée est envoyée automatiquement à la LSQ, pas besoin de préciser la destination. [[File:ALU et LSQ.png|centre|vignette|upright=2|AGUs et Load-Store Queue.]] Dans les deux cas, les µops AGU sont alors considérées comme des opérations entières un peu spéciales, gérées par le système d'exécution dans le désordre. Elles peuvent être émises dans le désordre, mais la LSQ se chargera de faire les accès mémoire dans l'ordre. Il y a donc calcul des adresses dans le désordre, mais accès mémoire dans l'ordre. ==Les ''schedulers'' mémoire unifiés== Avec la LSQ précédente, les µops mémoire s'exécutent dans l'ordre. Il arrive que des lectures prêtes soient mises en attente, parce qu'une lecture précédente attend ses opérandes. Et ce retard sur les lectures se répercute sur les instructions lecture-dépendantes, donc sur le reste du pipeline. Mais avec l'implémentation qui va suivre, il est possible exécuter les lectures dans le désordre tant que les conditions d'''aliasing'' sont respectées. L'implémentation améliore la LSQ pour en faire un ''scheduler'' spécialisé dans les µops mémoire. Il sera appelé le '''''scheduler'' mémoire''' dans ce qui suit, mais il est souvent appelé la LSQ dans certains documents. [[File:Processeur avec émission dans le désordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans le désordre des accès mémoire]] Une entrée du ''scheduler'' mémoire contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures (inutile pour les écritures). Elle contient aussi des bits de validité pour chaque champ qui indiquent si l'adresse d'accès est connue, ou si la donnée à écrire est disponible pour une écriture. Les champs pour la donnée à écrire ou le registre de destination sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2.5|''Load/Store Queue''.]] Lorsqu'une micro-opération mémoire est émise, elle est insérée dans le ''scheduler'' mémoire. Si l'adresse à lire/écrire est disponible, elle est immédiatement lue depuis les registres, idem pour une éventuelle donnée à écrire. Sinon, la micro-opération attend dans le ''scheduler'' mémoire que son adresse soit calculée par le processeur. ===Les calculs d'adresse avec un ''scheduler'' mémoire=== Le ''scheduler'' mémoire détecte des dépendances d'adresse entre instructions. Pour cela, il compare toutes les adresses à lire/écrire. Il mémorise donc les dites adresses, ce qui en fait une station de réservation. Et cela implique que ces adresses ont été calculées ailleurs, dans une ALU entière, ou dans une AGU séparée de l'unité mémoire. L'implémentation la plus simple effectue les calculs d'adresse dans les ALU entières. La sortie de toutes les ALU entière est reliée au ''scheduler'' mémoire. [[File:Contournement pour la file de µops mémoire.png|centre|vignette|upright=2|''scheduler'' mémoire]] Une autre implémentation utilise des unités de calcul d'adresse dédiées, afin de simplifier le réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse via AGU.png|centre|vignette|upright=2|ALU et ''scheduler'' mémoire]] Une conséquence est que l'implémentation impose que toutes les instructions mémoire sont découpées en deux µops : une µop pour calculer l'adresse, et une µop mémoire. C'est un défaut assez important, car une instruction mémoire prendra deux places dans le ROB, dans les stations de réservation, etc. Vu que les instructions mémoire sont très fréquentes, la capacité du ROB/autres est alors fortement réduite. Il faut noter que les adresses calculées dans l'ALU/AGU doivent atterrir dans l'entrée adéquate, celle de la µops qui doit recevoir l'adresse. La seule solution est que le ''program counter'' de la µops est transmis dans le pipeline, propagé avec la µops de calcul d'adresse. Quand le résultat est calculé, le ''program counter'' associé est envoyé avec au ''scheduler'' mémoire. Le ''scheduler'' mémoire enregistre alors l'adresse dans l'entrée qui est taguée avec cette adresse. ===La détection des dépendances mémoire=== Le ''scheduler'' mémoire intègre un système de détection des dépendances, qui est bien différent de celui d'une station de réservation normale. Les ''schedulers'' mémoire comparent non pas des noms de registres, mais des adresses/opérandes. Pour détecter les dépendances, le ''scheduler'' mémoire compare les adresses à lire/écrire. Pour les µops dont l'adresse est inconnue, le cas dépend de l'implémentation. La version la plus conservatrice suppose qu'elle a des dépendances possibles avec toutes les autres. Chaque micro-opération en attente compare son adresse avec celle de toutes les micro-opérations précédentes. Ou presque, la comparaison ne doit tenir compte que des écritures, et de celles situées avant dans l'ordre du programme. Le ''scheduler'' mémoire doit donc mémoriser les instructions dans l'ordre du programme, tout en faisant des comparaisons d'adresse. Ça en fait une sorte hybride entre mémoire associative et mémoire FIFO. Nous avions vu dans le chapitre "L'exécution dans le désordre" qu'il y a plusieurs manières pour implémenter une station de réservation : avec une mémoire associative, avec préplanification, avec des matrices de dépendances. Le problème est qu'on ne peut pas utiliser une mémoire associative pour le ''scheduler'' mémoire, qui utilise des comparateurs pour comparer les noms de registres/adresses. En effet, la détection des dépendances mémoire demande de faire beaucoup de comparaisons : chaque adresse est comparé à toutes les autres. Et les adresses faisant facilement 32 à 64 bits, les comparateurs sont bien plus imposants que ceux qui comparent des noms de registres de 5-10 bits. Le cout en matériel serait trop élevé, de même que le temps de calcul des dépendances. À la place, on utilise des matrices de dépendances. La détection des dépendances mémoire utilise précisément deux matrices de ce type : une matrice de disponibilité, et une matrice de dépendance. Ces matrices forment une espèce de tableau carré, organisé en lignes et en colonnes. L'instruction dans la énième entrée se voit attribuer la énième ligne et la énième colonne. À l'intersection d'une ligne et d'une colonne, on trouve un bit qui indique si l'instruction de la ligne et celle de la colonne ont une dépendance. La '''matrice de disponibilité''' permet de bloquer l'exécution des lectures/écritures tant qu'une instruction précédente n'a pas calculé son adresse, tant qu'on ne peut pas vérifier les dépendances. Lorsqu'une instruction est ajoutée dans le ''scheduler'' mémoire, elle met tous les bits de la colonne attribuée à 1 si l'adresse à lire/écrire est inconnue, puis les met à 0 une fois l'adresse calculée. Une instruction ne peut pas démarrer si, sur la ligne qui lui est attribuée, se trouve un 1. Plus précisément, si on trouve un 1 pour les instructions plus anciennes, ce qui fait qu'une partie de la matrice est ignorée. Les bits notés X dans le tableau suivant sont ignorés, car ils précisent la disponibilité des opérandes d'un opérande ultérieure, ce qui est inutile. En clair, les calculs d'adresse modifient les colonnes, et on vérifie les lignes pour autoriser l'exécution. {|class="wikitable" |- ! Instructions mémoire dans la LSB, triées de la plus ancienne à la plus jeune ! 0 !! 1 !! 2 !! 3 !! 4 !! 5 !! 6 |- ! 0 | 1 || X || X || X || X || X || X |- ! 1 | 0 || 0 || X || X || X || X || X |- ! 2 | 0 || 1 || 0 || X || X || X || X |- ! 3 | 0 || 1 || 1 || 1 || X || X || X |- ! 4 | 0 || 0 || 0 || 0 || 1 || X || X |- ! 5 | 0 || 0 || 1 || 0 || 0 || 1 || X |- ! 6 | 1 || 1 || 1 || 1 || 1 || 1 || 0 |} Les '''matrices de dépendances''' fonctionnent sur le même principe, mais déterminent les dépendances mémoire. Lorsque le processeur a calculé une adresse, il la compare avec les adresses à lire/écrire déjà dans le ''scheduler'' mémoire. Il met alors à jour les bits de la colonne de la matrice de dépendance : le bit à l'intersection d'une ligne et d'une colonne est mis à 0 si les deux instructions n'ont pas de dépendances, à 1 si elles en ont une. Une instruction mémoire est exécutée si tous les bits de sa ligne sont à 0. Là encore, on ne tient compte que des bits pour les instructions plus anciennes, la matrice a encore une fois une forme triangulaire, comme pour la matrice de disponibilité. Une optimisation scinde le ''scheduler'' mémoire en deux structures, aux rôles légèrement différents. La première gère la disponibilité des opérandes, la seconde gère seulement les dépendances mémoire. La première est une station de réservation où les instructions sont mises en attente tant que leurs opérandes ne sont pas prêts. La seconde met en attente des micro-opérations dont les opérandes sont prêts, mais qui doivent attendre pour une autre raison : dépendance mémoire, cache occupé, etc. L'avantage est qu'on remplace une grosse station de réservation par deux plus petites, mais l'intérêt est surtout relié au système de détection des dépendances. Sans cette optimisation, on a deux matrices énormes reliées à une ''Load/Store Queue'' unique. Avec la séparation, la matrice de disponibilité est reliée uniquement à la première station de réservation, alors que la matrice de dépendance est reliée uniquement à l'autre. Les deux matrices sont donc plus petites, car reliées à deux stations de réservation mémoire plus petites. ==L'exécution spéculative des accès mémoires== Pour rappel, le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. Les données lues sont en effet, le départ d'une série d'instructions dépendantes, généralement des instructions arithmétiques, mais parfois d'autres lectures. Quand on manipule des structures de données, il n'est pas rare qu'une lecture lise un pointeur-adresse utilisé par une autre lecture. Les techniques de désambiguïsation mémoire que nous avons vu précédemment sont dites conservatrices, à savoir qu'elles ne changent pas l'ordre de deux instructions mémoire si elles ont une dépendance d'adresse. Elles exécutent une lecture le plus tôt possible, mais seulement si on sait que celle-ci n'aliase pas d'écritures précédentes. Et cela demande de connaitre les adresses des écritures précédentes dans l'ordre du programme. Si une lecture est précédée par une ou plusieurs écritures, les adresses à écrire doivent être connues. Il se peut en effet qu’une écriture dont on ne connait pas l'adresse aliase la lecture. La garantie est liée à la présence de la file de µops mémoire ou la file de µops STORE. Si une seule écriture a une adresse inconnue, elle reste dans la file de µops mémoire et bloque l'émission de toutes les autres micro-opérations mémoire, lecture comme écriture. Mais il existe des techniques dites spéculatives, qui modifient l'ordre des accès mémoire sans se soucier de leurs dépendances. Elles émettent les lectures dès que leurs opérandes sont prêts, sans se préoccuper de l'aliasing avec une écriture précédente. Pour éviter tout problème, le processeur vérifie si aucune dépendance mémoire n'est violée et corrige le tir dès qu'une violation est détectée. Le processeur vérifie s'il a fait une erreur de prédiction, en vérifiant les adresses à écrire ou lire. En cas d'erreur, le processeur fait comme lors d'une prédiction de branchement ratée : il remet le pipeline en ordre et ré-exécute les accès mémoire dans le bon ordre. Il s'agit d'un cas particulier d’'''exécution spéculative''', au même titre que la prédiction de branchement. Toutes les techniques de ce genre exécutent des lectures dans le désordre. L'avantage est que cela permet d'exécuter les lectures en avance. Vu que la donnée lue est l'opérande d'autres instructions, les exécuter en avance fait prendre de l'avance à un paquet d'instructions. Mais il faut comparer cet avantage au risque de mauvaise prédiction. Et aussi bizarre que cela puisse paraître, cela donne de bons résultats. Il faut dire que les situations où une lecture relit une donnée tout juste écrite sont rares : même pas 2 à 3 % des lectures. Les dépendances mémoire sont donc très rares, les cas d'''aliasing'' sont vraiment limités. En termes de cout en transistors, le hardware qui vérifie les dépendances mémoire reste globalement le même à peu de choses près, il est juste déplacé dans le pipeline et adapté à sa nouvelle fonction. ===La file de lectures=== La technique que nous allons voir émet les lectures dans l'ordre, ce qui simplifie énormément la gestion des dépendances. Les lectures sont émises dans l'ordre, les écritures aussi. Par contre, l'ordre entre lectures et écritures n'est pas respecté. Une lecture peut passer avant une écriture sans problèmes, voire avant plusieurs écritures d'un seul coup. Une telle lecture exécutée en avance d'une autre écriture est appelée une '''lecture spéculative'''. Une lecture spéculative peut donc sauter devant une ou plusieurs écritures, qui seront appelées des '''écritures sautées'''. Une écriture sautée est une précédant une lecture spéculative, dans l'ordre du programme. Évidemment, il se peut qu'une écriture sautée écrive la donnée qui est lue par la lecture spéculative. Il y a alors une violation de dépendance mémoire. Mais le processeur suppose qu'il n'y a pas de dépendantes de ce type et émet la lecture en avance. Rassurez-vous, il surveille quand même la situation, pour tout annuler ensuite s'il détecte une violation de dépendance mémoire. J'ai dit plus haut que le processeur émet les lectures dans l'ordre, ainsi que les écritures dans l'ordre. Pour cela, la LSQ est scindée en deux files de µops : une pour les µops LOAD et une autre pour les µops STORE. Les cours d'architecture des ordinateurs nomment souvent ces deux files en utilisant les termes de ''file de STORE'' (''store queue'') et de ''file de LOAD'' (''load queue''), mais il y a un risque de confusion avec la file d'écriture vue précédemment, et la file de lecture qu'on introduira dans la suite. Dans ce qui suit, nous parlerons de '''file de µops LOAD''' et de '''file de µops STORE''', pour éviter toute confusion. Les deux files sont chacune connectées à l'unité mémoire, via un port dédié. L'unité mémoire a donc un port pour les lectures, et un port pour les écritures. Le port d'écriture est relié à la file d'écriture, le port de lecture est directement relié au cache de données. Il faut noter qu'une unité mémoire à deux ports n'est pas si bizarre que cela. En effet, le cache de données a lui aussi deux ports, dans la majorité des cas, avec un port de lecture et un port d'écriture. [[File:Load Ordering, Store Ordering.jpg|centre|vignette|upright=2|Exécution dans le désordre des lectures.]] Maintenant, voyons comment sont détectées les violations de dépendances mémoire. Les dépendances qui peuvent être violées dans ce cas de figure se font entre une écriture sautée et une lecture spéculative. Si une lecture spéculative lit une donnée qui est écrite par une écriture sautée, alors une dépendance RAW a été violée. Pour les détecter, le processeur garde une trace des lectures spéculatives; tant que les instructions précédentes ne sont pas terminées. Pour cela, il utilise une file d'attente, qui mémorise les lecture spéculatives. Nous l'appellerons la ''file de vérification des lectures'', ou encore la '''file de lectures'''. Elle est une sorte d'équivalent de la file d'écriture, mais pour les lectures. Les lectures sortent de la file µops LOAD dès que leurs adresses sont calculées, sans tenir compte des dépendances mémoires. Elles sont alors ajoutées à la file de lectures et s'exécutent immédiatement, de manière spéculative. Pour vérifier qu'il n'y a pas d'erreur de spéculation, le processeur conserve l'adresse de la lecture effectuée dans la file de lectures. Les lectures quittent la file de lecture quand toutes les instructions précédentes dans l'ordre du programme sont terminées, à savoir tant quand elles ont quitté le tampon de réordonnancement/ROB. Notons que la file de vérification des lectures contient les lectures dans l'ordre du programme. En effet, les lectures sont accumulées en sortant de la file de µops LOAD, où elles sont triées dans l'ordre du programme. Et cela permet de gérer les erreurs de prédiction parfaitement, en annulant les instructions suivant une erreur de prédiction. [[File:Spéculation avec une file de lecture.jpg|centre|vignette|upright=2.5|Spéculation avec une file de lecture.]] Reste à détecter les erreurs de prédiction, ce qui peut être fait avec deux méthodes. La première détecte les violations de dépendances mémoire à chaque écriture. L'idée est que chaque écriture vérifie si une lecture anticipée n'a pas lue la donnée écrite en avance. Pour cela, quand une écriture est émise, elle consulte la file de lecture pour voir s'il y a un conflit d'adresse. La vérification fait cependant gaffe à ne prendre en compte que les lectures situées après l'écriture dans l'ordre du programme. Précisément, elle envoie l'adresse à écrire à la file de lecture et cherche si une lecture ultérieure a eu lieu à la même adresse. Si c'est le cas, cela signifie qu'une lecture anticipée aurait du lire la donnée actuellement écrite, mais que la lecture a été exécutée trop en avance. La lecture n'a pas lu la bonne donnée, il y a eu une violation de dépendance mémoire. Pour faire cette vérification, la file de lecture est un mélange entre FIFO et cache/mémoire associative, sur le même modèle que la file d'écriture. La différence est que la consultation de la file de lecture ne sert pas à faire du contournement, mais à détecter les dépendances. Quand une dépendance est détectée, l'écriture se fait normalement. Par contre, la lecture marquée comme invalide dans la file de lecture. L'exécution du programme se poursuit, jusqu'à ce que la lecture arrive à l'étape de ''commit'', à savoir que le ROB lui dit que les instructions précédentes sont terminées sans encombre. Là, la lecture étant invalide, le processeur déclenche une procédure de réparation, qui vide partiellement le pipeline de toutes les instructions qui suivent la lecture. La lecture est de plus ré-executée, afin de lire la bonne donnée. Avec la seconde méthode, les violations de dépendance mémoire sont détectées au moment où la lecture quitte à la file de lectures et le ROB. L'idée est de comparer la donnée lue de manière spéculative avec la donnée lue de manière non-spéculative. Pour faire la comparaison, la lecture spéculative copie la donnée lue dans la file de lecture. Quand la lecture quitte la file de lecture et le ROB, la lecture est ré-exécutée et la donnée relue. Le processeur compare alors la donnée lue à ce moment avec la donnée mémorisée dans la file de lecture. Si la donnée est différente, alors il y a eu erreur de violation de dépendance mémoire. Évidemment, relire une donnée n'est pas gratuit. Il est possible de limiter son impact en performance en dédiant un port de lecture sur le cache rien que pour ça. Mais le cout en circuit est comparable à l'usage d'une FIFO associative de la technique précédente. Mais quoi qu'il en soit, le cache doit permettre des lectures en un seul cycle, sans quoi la méthode ne marche pas vraiment. ===La prédiction de dépendances mémoires=== Certains processeurs essayent de prédire si deux accès mémoires sont dépendants : ils incorporent une unité qui va fonctionner comme une unité de prédiction de branchement, à la différence qu'elle prédit les dépendances mémoires. Si cette unité prédit que deux accès mémoires sont indépendants, le processeur les exécute dans le désordre, et les exécute dans l'ordre du programme dans le cas contraire. Il faut prendre en compte les erreurs de prédiction, ce qui est fait comme pour les mauvaises prédictions de branchement : on vide le pipeline. Beaucoup de techniques de prédiction des dépendances mémoire ont été inventées et en faire une revue exhaustive serait long et fastidieux. On pourrait citer les ensembles colorés (''color sets''), et bien d'autres techniques. Mais nous n'allons parler que des techniques les plus élémentaires. Quoi qu’il en soit, la recherche sur le sujet est assez riche, comme toujours quand il s'agit d'architecture des ordinateurs. On peut améliorer cette technique en mémorisant les instructions qui ont causé une mauvaise prédiction de dépendance mémoire. La prochaine fois qu'on exécute ces instructions, on sait qu'il y a de grandes chances qu'il se produise une erreur de prédiction, ce qui pousse à ne pas les exécuter de manière spéculative. On peut pour cela réutiliser les techniques de prédiction de branchement, tels des compteurs à saturations, mis à jour en cas d'erreurs de prédiction de dépendances mémoire. Dans tous les cas, on trouve un cache, équivalent au ''branch target buffer'', qui mémorise les instructions fautives (leur ''Program Counter''), avec d'autres informations comme les adresses des instructions dépendantes. La première classe de techniques du genre consiste à mémoriser les lectures qui ont causé une violation de dépendance, tandis que l'autre ne mémorise que les écritures. Cette dernière est l'approche utilisée par le '''cache de barrières d’écriture''' (store barrier cache). Enfin, nous allons conclure avec une dernière technique : celle des '''ensembles d’écritures''' (store sets). Cette technique est capable de gérer le cas où une lecture dépend de plusieurs écritures : le processeur mémorise l'ensemble des écritures avec lesquelles la lecture a déjà eu une dépendance. Cet ensemble d'écritures associées à une lecture est appelé tout simplement un ensemble d’écritures, et reçoit un identifiant (un nombre) par le processeur. Cette technique demande d'utiliser deux tables : * une qui assigne un identifiant d’ensemble d’écritures à l'instruction en cours ; * une autre qui mémorise les ensembles d’écritures sous la forme d'une liste chainée : le début de la liste correspond à l'écriture la plus ancienne de l’ensemble. Cela permet d'obtenir l'écriture la plus ancienne dans cet ensemble d’écritures directement. Quand le processeur détecte une violation de dépendance entre une lecture et une écriture, elle ajoute l'écriture dans l’ensemble d’écritures adéquat. Le déroulement d'une lecture demande d'accéder à la première table pour récupérer l'identifiant de l’ensemble d’écritures, et l'utiliser pour adresser la seconde table. S'il y a dépendance, cet accès renvoie l'écriture fautive en cas de dépendance. Quand une écriture est envoyée à l'unité mémoire, celle-ci va accéder à la table de correspondances de la même manière qu'une lecture, et va récupérer l'identifiant de l’ensemble d’écritures auquel elle appartient, identifiant qui est utilisé pour vérifier s'il y a une écriture en cours d’exécution. Si c'est le cas, cela signifie que l'écriture en cours d’exécution doit s’exécuter avant l'écriture qui a consulté la table : cette dernière est mise en attente. ==La prédiction d'adresse et de valeur== D'autres techniques de prédiction mémoire plus élaborées que les précédentes existent. Il s'agit de la prédiction d'adresse et de la prédiction de valeur. Leur nom trahit ce qu'elles font. La première technique tente de prédire l'adresse d'une lecture/écriture, alors que la seconde tente carrément de prédire quelle sera la donnée lue. Il y a bien de la recherche académique sur le sujet, mais il s'agit de techniques assez osées, dont on voit mal comment elles pourraient fonctionner. L'efficacité de cette technique a été étudiée grâce à des simulateurs. Suivant les études ou les programmes, on trouve des résultats qui varient pas mal. Dans certains cas, la performance baisse un peu, et dans d'autres, on peut avoir des gains de plus de 10% ! Mais dans des cas normaux, on trouve des gains de 4-5% environ. Ce qui est tout de même pas mal à l'heure actuelle. Cependant, en Janvier 2025, à l'heure où j'écris ces lignes, il a été découvert la première utilisation de ces deux techniques dans un processeur commercial. La découverte fait suite à la publication de deux vulnérabilités matérielles sur les CPU Apple M2 et M4 et quelques CPU ARM, qui font justement usage de ces deux techniques. Les deux vulnérabilités, appelées SLAP et FLOP, sont assez similaires aux attaques Spectre et Meltdown, sauf qu'elles attaquent non pas la prédiction de branchement, mais la prédiction d'adresse et de valeur du CPU. ===La prédiction d'adresse=== La prédiction d'adresse tente de prédire l'adresse d'une lecture à l'avance, ce qui permet de lancer les lectures en avance. Si la prédiction est correcte, on gagne quelques cycles qui permet aux unités de calcul de faire leurs calculs plus en avance, sans compter que les mécanismes de désambiguïsation mémoire fonctionnent plus efficacement. Si jamais cette adresse est connue plus tôt, on détecte les dépendances plus rapidement et on peut agir en conséquence. Des variantes de ces unités de prédiction d'adresse sont utilisées pour précharger des données depuis le cache de donnée. Et leur efficacité est assez bonne, voire excellente dans certains cas. La différence est que la prédiction d'adresse est utilisée pour prédire les adresses à lire depuis le cache vers les registres, ce qui est le sujet de cette sous-partie. La prédiction d'adresse est réalisée par une sorte de cache, qui associe à chaque instruction la prochaine adresse à lire. Nous appellerons ce cache la '''table de prédiction d'adresse'''. Pour faire l'association instruction <-> adresse lue/écrite, le tag du cache utilise l'adresse de l'instruction (le ''program Counter''), alors que la ligne de cache associée contient l'adresse prédite. Son contenu est utilisé pour faire une prédiction. Lorsqu'une lecture est émise, la table est consultée pour récupérer en avance l'adresse à lire, avant que celle-ci soit calculée par l'ALU. La lecture est alors exécutée en avance, avant que la vraie adresse soit connue, en utilisant l'adresse prédite. Reste qu'il faut des circuits autour de cette table de prédiction d'adresse, pour déterminer si la prédiction est correcte, ainsi que pour remplir cette table avec une adresse prédite. Pour cela, il y a un circuit de prédiction d'adresse dédié. Il récupère les instructions qui quittent le ROB ou la file des lectures mémoire, afin de vérifier les lectures qui viennent de se terminer/''commit'' et qui quittent le pipeline. Il peut contenir une '''table d'apprentissage''', qui mémorise des informations nécessaires pour faire des prédictions d'adresse. Par exemple, un historique des dernières adresses lues pour les lectures les plus récentes, ou un historique des adresses des lectures. La table est aussi utilisée pour déterminer si les prédictions effectuées étaient correctes ou non. Pour cela, il est possible de réutiliser les techniques vues dans le chapitre sur la prédiction de branchement, notamment des compteurs à saturation. Pour la prédiction de branchement et les autres formes de prédiction mémoire, les table d'apprentissage et pour les instructions/adresses sont fusionnées en une seule. Mais ici, il est nécessaire de les séparer pour une raison assez particulière : éliminer la pollution du cache dans la table de prédiction d'adresse. On ne peut pas allouer une ligne de cache pour chaque nouvelle instruction de lecture exécutée. Seule une minorité des lectures profitent de la prédiction d'adresse. Il est nécessaire de filtrer les lectures pour ne conserver que celles qui profitent de la prédiction d'adresse, ce qui est possible plus facilement en séparant la table d'apprentissage et les compteurs à saturation de la table de prédiction d'adresse. Une première méthode consiste à prédire que la lecture lit toujours la même adresse, ce qui lui vaut le nom de '''prédiction d'adresse constante'''. On pourrait se demander pourquoi une telle situation surviendrait. La raison est qu'il arrive que les compilateurs n'arrivent pas à gérer les accès mémoires efficacement. Dans certaines situations un peu compliquées impliquant des pointeurs ou des références, divers phénomènes complexes d'''aliasing'' des pointeurs peuvent générer des relectures intempestives de données en mémoire. Cela peut aussi arriver lorsqu'on compile du code qui provient de plusieurs librairies, ou quand du code lit régulièrement des constantes en mémoire ROM. La prédiction d'adresse constante ajoute un circuit qui analyse les dernières lectures, une fois qu'elles quittent le ROB, qu'elles ''commit''. Si, pour une instruction de lecture, l'adresse lue est toujours la même, alors l'instruction est ajoutée dans la table de prédiction d'adresse. Vu que les instructions qui accèdent toujours à la même adresse sont rares, il est préférable d'initialiser les compteurs à saturation de façon à ce qu'ils disent que toute nouvelle instruction change d'adresse. Il s'agit d'une méthode simple mais peu efficace, car cette situation est rare. Une autre méthode détecte les accès en enjambées, là encore quand les lectures quittent le ROB. Nous l’appellerons la '''prédiction d'adresse en enjambées'''. De tels accès surviennent régulièrement quand on accède des tableaux ou d'autres structures de données semblables, ce qui rend la technique très intéressante. Implémenter cette technique est facile : il suffit d'ajouter un circuit qui détecte les accès en enjambées, au lieu de celui qui détecte les accès constants. À chaque fois qu'une lecture entraine un succès de cache dans la table de prédiction d'adresse, l'enjambée est ajoutée à l'adresse contenue dans la table de prédiction d'adresse, l'ancienne adresse prédite est remplacée par celle avec l'enjambée d'ajoutée. Sur les Apple M2-M4, la prédiction d'adresse gére aussi bien les adresses constantes que les accès en enjambée. Le brevet qui décrit l'implémentation de l'unité de prédiction d'adresse et de valeur est potentiellement le suivant : [https://patents.google.com/patent/US11829763B2/en Early load execution via constant address and stride prediction]. En théorie, il est possible de faire mieux en repérant des accès mémoires qui se répètent de façon régulière et cyclique, même s'ils n'ont aucune enjambée. Ce genre d'accès se trouve assez souvent lorsque l'on manipule des listes chainées ou des structures de données assez irrégulières comme des arbres, des graphes, etc. Mais il n'y a pas encore de CPU qui implémente de telle optimisation. ===La prédiction de valeur : prédire la donnée lue=== Les techniques de '''prédiction de valeur''' (''Value Prediction'') consistent à prédire quelle est la donnée qui sera lue par une instruction de lecture. Oui, vous avez bien lu : le processeur est capable de parier sur la valeur qui sera chargée depuis la mémoire, de prédire si cette valeur vaut 0, 1, 1024, etc. Une fois son pari fait, il exécute les instructions du programme avec la valeur qu'il a pariée de façon spéculative. Si le pari est correct, le CPU continue l’exécution. Sinon, il est obligé de vider le pipeline et de recommencer avec la bonne valeur. Au premier abord, cette technique semble franchement mal partie tellement les chances de se tromper sont tellement énormes ! Mais le fait est que, dans certains cas, il est possible de spéculer correctement. Là encore, les techniques les plus simples fonctionnent dans deux cas. Le premier est celui où une donnée est relue à l'identique à chaque fois que la lecture est exécutée. Le second est celui où la donnée est incrémentée/décrémentée d'une constante qui est toujours la même d'une exécution de la lecture à l'autre. Une sorte d'enjambée constante, mais pour les données. Nous allons nous concentrer sur le premier cas, celui où une donnée est relue plusieurs fois sans être modifiée entre temps. L'idée de base est qu'une instruction de lecture qui s'exécute plusieurs fois de suite à la même adresse renvoie la même valeur à chaque fois. C'est parfaitement possible s’il n'y a eu aucune écriture à cette adresse entre-temps, d'où le fait qu'il faille surveiller si les prédictions constantes sont correctes. On peut se demander quelles sont les raisons qui font qu'une instruction de lecture renvoie la même valeur à chaque fois. Après tout, autant lire une seule fois la donnée et la garder dans un registre une bonne fois pour toutes ! Mais dans la réalité, on fait face à quelques limites, qui seront détaillées dans la liste suivante. La première est que le manque de registre fait que certaines données sont conservées en RAM et sont relues fréquemment. Cela arrive quand on stocke des constantes en mémoire. Par exemple, sur les processeurs x86, les constantes flottantes ne peuvent pas être intégrées dans nos instructions via le mode d'adressage immédiat. À la place, on les stocke en mémoire et on les charge dans les registres à chaque fois qu'on en a besoin. L'implémentation est similaire à la prédiction d'adresse, si ce n'est que c'est la donnée lue qui est prédite. Là encore, on a une '''table de prédiction de donnée lue''', consultée lorsqu'une lecture est émise. La table de prédiction de la donnée lue est une mémoire cache qui associe le ''Program Counter'' de la lecture à la donnée prédite, éventuellement couplé à des compteurs à saturation. Elle est consultée à chaque fois qu'une lecture est émise, la donnée adéquate est récupérée lors d'un succès de cache. On trouve aussi un circuit qui insère ou retire des instructions de la table de prédiction de donnée lue. Elle détecte les lectures prédictibles, couplé à une table d’apprentissage, et quelques circuits annexes liés au ROB ou aux files de lectures. Elle contient au minimum des compteurs à saturations, associés chacun à une instruction (son ''program counter'' pour être précis). ==L'optimisation du ''Mirroring memory operand''== Les processeurs modernes implémentent une optimisation concernant les accès mémoire. Cette optimisation a été décrite par Agner Fog dans ses manuels d'optimisation, sous le terme de '''''Mirroring memory operand'''''. Il a documenté son apparition sur les processeurs Zen 2 d'AMD, puis son évolution sur les processeurs Zen 4 et 5. L'optimisation est bizarrement absente de la microarchitecture Zen 3. Elle se manifeste dans une condition très précise : plusieurs instructions proches accèdent à la même adresse. Les deux instructions ne sont pas forcément consécutives, il peut y avoir une dizaine ou centaine d'instructions entre les deux. Une telle situation survient souvent sur les CPU CISC, et notamment sur les CPU x86, car ils ont peu de registres. les compilateurs font donc beaucoup d'échanges entre pile d'appel et registres, ce qui fait que des données écrites dans la pile sont souvent relue quelques instructions plus tard. Dans ce cas, la donnée lue/écrite est mémorisée dans un registre interne au processeur et peut être accédée très rapidement. Par exemple, imaginons plusieurs lectures proches à la même adresse, sans écriture entre elles. Dans ce cas, le processeur ne fera qu'une seule lecture en mémoire RAM, les lectures ultérieures liront la donnée depuis le registre interne. Comme autre exemple, imaginons une écriture suivie plus tard par une lecture à la même adresse. Dans ce cas, la donnée sera écrite dans un registre interne, en plus d'être propagée dans le cache ou la RAM, et la lecture ultérieure lira directement le registre interne. Le gain en performance est alors conséquent, comparé à du simple ''store-to-load forwarding''. Il faut noter que la technique marche aussi pour les opérations ''load-op'', pas seulement pour les instructions d'accès mémoire. Par exemple, prenons une écriture en RAM, suivies quelques instructions plus tard par une opération ''load-op'' à la même adresse. L'écriture écrit à une adresse, qui est lue par l'instruction ''load-op''. L'optimisation s'applique : l'instruction ''load-op'' lira l'opérande mémoire depuis un registre interne du processeur, qui contient une copie de la donnée écrite. Sur les processeurs Zen 2, l'optimisation marchait aussi pour les instructions PUSH et POP, qui lisaient/écrivaient dans la pile d'appel. Mais cette possibilité a été retirée sur les processeurs Zen 5. La technique détecte que deux instructions accèdent à la même adresse sous certaines conditions bien précises. L'optimisation ne marche que pour les modes d'adressages indicés et indirect, avec ou sans décalages, mais pas en mode absolu ou relatifs. Il faut préciser que l'optimisation ne marche que pour les registres généraux, pas les registres flottants. Les registres doivent être précisés de la même manière dans toutes les instructions. Par exemple, le processeur ne reconnait pas que l'addition des deux registres RCX + RAX est la même que RAX + RCX. Sur les CPU Zen 2 et 4, elle ne marchait que pour des opérandes de 32 et 64 bits, mais les processeurs Zen 5 gèrent des données de 8 et 16 bits aussi. Les décalages étaient limités à 8 bits sur le Zen 2, mais n'a plus de limites sur les Zen 4 et 5. ==La désambiguïsation mémoire a des effets de bord== La désambiguïsation mémoire a une caractéristique que l'exécution dans le désordre simple n'a pas : elle a des effets qui débordent sur la mémoire. Il est possible de savoir si un processeur fait de la désambiguïsation mémoire en regardant le bus mémoire. On s’aperçoit alors que les lectures se font dans un ordre différent de celui du programme. À l'opposé, l'exécution dans le désordre n'a pas d'effets observables de ce genre. Elle améliore la performance, elle modifie les durées observables des instructions, mais cela se limite à des questions de ''timings''. Le seul moyen de savoir si un processeur fait de l'exécution dans le désordre est de mesurer les latences/durées de chaque instruction. Mais aucun état observable au-delà des ''timings'' n'est modifié. Pas avec la désambiguïsation mémoire. ===La consistance mémoire=== Le fait que la désambiguïsation mémoire ait des effets observables ne pose aucun problème avec un seul processeur, ca elle est conçue pour. Par contre, les techniques de désambiguïsation mémoire peuvent poser des problèmes avec plusieurs processeurs. Il arrive que des programmes partagent une même portion de mémoire, et lisent/écrivent dedans. Avec plusieurs processeurs, chaque processeur effectue la désambiguïsation mémoire dans son coin sans se préoccuper des autres. Les opérations d'écriture peuvent être mises dans le désordre du point de vue des autres processeurs, et les lectures effectuées par les autres processeurs peuvent alors renvoyer de vielles données. Un exemple classique est celui de deux cœurs qui partagent le même cache ou la même mémoire, mais qui utilisent chacun une file d'écriture (''store buffer''). Imaginons que le premier cœur effectue une écriture, puis une lecture, à des adresses différentes. L'écriture est retardée grâce au tampon d'écriture, alors que la lecture lit directement le cache. Pour le premier processeur, l'écriture s'est réalisée avant la lecture, car pour lui, la fin de l'écriture signifie écrire dans le tampon d'écriture. Mais pour le second cœur, l'ordre s'est inversé : il voit la lecture dans le cache, puis l'écriture dans le cache. La raison est qu'il n'a pas connaissance du contenu du tampon d'écriture de l'autre cœur. Un autre exemple : les techniques de prédiction d'adresse et de valeur posent des problèmes avec les dépendances RAW. Elles permettent à une instruction mémoire de s'exécuter avant une autre, malgré la présence d'une dépendance RAW entre les deux. L'ordre entre les deux instructions change, alors que les autres processeurs ne devraient pas le voir. Et dans les faits, les processeurs qui incorporent ces techniques ont des modèles de consistance mémoire qui ne permettent pas ces optimisations en cas de dépendances RAW. Les deux techniques doivent idéalement être désactivées pour les instructions mémoire qui ont des dépendances RAW. Les programmeurs doivent tenir compte de ce genre de situations, dans des cas assez rares et liés à la programmation bas niveau. Pour cela, ils doivent savoir comment le processeur réorganise les accès mémoire. Concrètement, chaque processeur définit un '''modèle de consistance mémoire''' qui indique quelles optimisations sont activées et non. Il s'agit d'une sorte de contrat entre le logiciel et le matériel : les programmeurs savent comment le processeur va réorganiser les lectures et écritures et peuvent coder leurs programmes en fonction. L'implémentation du système de consistance mémoire dépend grandement de l'architecture, mais des modèles ont été standardisés afin de permettre aux programmeurs de savoir où placer les barrières mémoires. Il s'agit de vrais standards, formalisés, parfois mathématiquement, que des chercheurs étudient sur un plan mathématique et algorithmique. Le modèle préféré des programmeurs est la '''consistance séquentielle''', où les différents processeurs effectuent des accès mémoire dans l'ordre du programme et les accès mémoire simultanés sont interdits. La première contrainte interdit l'usage de mécanismes de désambiguïsation mémoire, la seconde interdit l'usage de caches non-bloquants. La consistance séquentielle a donc un cout en performance assez important, ce qui fait que les processeurs modernes n'utilisent pas la consistance séquentielle. Un modèle plus flexible est le '''''total store ordering''''', qui autorise la présence d'une file d'écriture et l'usage de caches non-bloquants pour les lectures (interdit d'avoir plusieurs écritures simultanées). C'est plus ou moins celui utilisé par les processeurs x86, comme on le verra plus bas. Il existe des modèles de consistance mémoire plus relâchés, appelés des '''modèles de consistance faible''', qui autorisent diverses optimisations de spéculation mémoire, la combinaison d'écriture (fusionner deux écritures à la même adresse en une seule), etc. Ils sont supportés sur les architectures ARM et POWER. ===Les barrières mémoire=== Le changement d'ordre des lectures et écritures peut poser occasionnellement des problèmes avec la programmation pour les architectures multi-processeur ou multicœurs. Il existe des morceaux de code qui ne marchent que si la consistance séquentielle est respectée et il faut faire en sorte qu'ils marchent sur des processeurs avec un modèle de consistance mémoire différent. Pour cela, les processeurs incorporent des instructions appelées '''barrières mémoires''' ou '''''Fences'''''. Elles forcent le processeur à terminer tous les accès mémoire qui précédent dans l'ordre du programme. Certains processeurs possèdent une barrière mémoire pour les lectures et une autre pour les écritures. D'autres processeurs ajoutent des barrières mémoires dédiées à des cas particuliers. Elles permettent de forcer les accès à des données partagées à dérouler correctement. [[File:Fences.png|centre|vignette|upright=2|Fences.]] Leur implémentation est assez simple : elles empêchent l'émission de toute nouvelle instruction mémoire, tant qu'une condition précise n'est pas réunie. Par exemple, la barrière mémoire pour les écritures attend que la file d'écriture soit vidée avant de démarrer le moindre accès mémoire. Un compilateur peut placer les barrières mémoires au bon endroit, avec un peu d'aide du programmeur. Certains langages de programmation permettent d'indiquer au compilateur qu'une donnée doit toujours être lue depuis la mémoire RAM, via le mot-clé volatile. C'est très utile pour préciser que cette donnée est potentiellement partagée par plusieurs processeurs ou manipulable par des périphériques. Les compilateurs peuvent placer des barrières mémoires lors des lectures ou écritures sur ces variables, mais ce n'est pas une obligation. Il arrive aussi que le programmeur doive manipuler explicitement des barrières mémoires. Utiliser l'assembleur est alors une possibilité, mais qui est rarement exploitée, pour des raisons de portabilité. Pour limiter la casse, certains systèmes d'exploitations ou compilateurs peuvent aussi fournir des barrières mémoires explicites, encapsulées dans des bibliothèques ou cachées dans certaines fonctions. ===La modèle de consistance des processeurs x86=== Après avoir vu la théorie, passons maintenant à la pratique. Dans cette partie, on va voir les modèles de consistances utilisés sur les processeurs x86, ceux qu'on retrouve dans les PC actuels. Le modèle de consistance des processeurs x86 a varié au cours de l'existence de l'architecture : un vulgaire 486DX n'a pas le même modèle de consistance qu'un Core 2 duo, par exemple. Ils ont évolués au cours du temps, avec l'arrivée de nouvelles optimisations. De plus, leur formalisation a été progressive, et s'est faite au fur et à mesure. En général, les modèles de consistance des processeurs x86 ont toujours étés assez restrictifs, si on les compare aux autres processeurs. Il s'agit de modèles de type ''total store ordering'' (TSO), au moins dans les grandes lignes. Ils permettent donc d'effectuer certaines lectures en avance, soit parce que les lectures se font à des adresses différentes, soit par utilisation de réacheminement écriture vers lecture. Les possibilités d'exécution en avance des lectures varient suivant le processeur, elles deviennent de plus en plus performantes avec le temps. Le premier modèle de consistance est apparu sur les premiers processeurs x86 et est resté en place sur tous les processeurs de marque Pentium. Les processeurs de l'époque n'incorporaient pas beaucoup d'optimisations, le budget en transistor n'était pas assez conséquent pour ça, la mémoire n'était pas assez lente. Le modèle TSO de l'époque n'autorisaient que peu de lectures anticipées. Une lecture ne peut être exécutée en avance que sous les conditions suivantes : * les écritures contournées se font dans la mémoire cache ; * la lecture doit se faire dans la mémoire RAM ; * les écritures se font à une adresse différente de la lecture ; * aucune transaction avec un périphérique ne doit être en cours. A partir du Pentium 4, les choses changent. Le Pentium 4 est en effet le premier processeur à implémenter des techniques permettant d’exécuter plusieurs processus en parallèle, dont l'Hyperthreading. En conséquence, le modèle de consistance a dû être assoupli pour éviter de perdre bêtement en performance. Il s'agit d'un vrai modèle de type TSO, la majeure partie des contraintes sur les lectures anticipées sont réduites au minimum. Il y a aussi des contraintes supplémentaires pour les instructions dites atomiques, ainsi que pour les instructions qui accèdent aux périphériques ou aux entrées-sorties. Une optimisation séparée du TSO est que les écritures en mémoire peuvent changer d'ordre dans un cas exceptionnel : les écritures effectuées par les instructions de copie mémoire comme REPMOVSD, REPSCACB, ainsi que les instructions SSE MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, MOVNTPD. Les écritures effectuées dans ces instructions peuvent se faire dans un désordre complet (ou presque). Lors du passage au 64 bits, le modèle mémoire des processeurs x86 de l'époque a été formalisé dans un papier d'Intel appelé [https://www.cs.cmu.edu/~410-f10/doc/Intel_Reordering_318147.pdf Intel® 64 Architecture Memory Ordering White Paper], mis à jour en 2008 suite à quelques problèmes techniques. La formalisation du modèle a été d'une grande aide aux programmeurs, qui devaient se débrouiller avec des documentations qui ne formalisaient pas de modèle de consistance précis. Le document décrit un modèle de consistance mémoire portant le nom barbare de ''total lock order + causal consistency” (TLO+CC)'', qui admet plus d'optimisations que le modèle TSO. Mais la description était en réalité légèrement différente du modèle mémoire réellement implémenté sur les processeurs existants, qui utilisaient du TSO, sauf en de rares occasions. Les concepteurs de processeur font souvent cela, à savoir définir un modèle de consistance plus laxiste que réellement implémenté, pour des questions de comptabilité. Ainsi, si les futurs processeurs de la marque implémentent un modèle plus laxiste, les programmes déjà codés continuent de fonctionner. Le modèle décrit dans la documentation laisse de la marge pour le futur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le parallélisme mémoire | nextText=Le parallélisme mémoire }} </noinclude> 8ppr4m0i0hj84lkm4gyvy8kglwpmo5c 774175 774174 2026-10-04T19:43:17Z Mewtow 31375 /* Les schedulers mémoire unifiés */ 774175 wikitext text/x-wiki Dans les chapitres précédents, nous avons vu la mal-nommée exécution dans le désordre dans les chapitres précédents, qui devrait plutôt s'appeler l'émission dans le désordre. Elle modifie l'ordre des instructions pour gagner en efficacité, mais cela n'est valable que pour les instructions travaillant sur des registres ! Nous n'avons pas parlé de l'exécution dans le désordre des accès mémoire, car ils sont un peu à part. Et pour comprendre pourquoi, nous allons dédier ce chapitre à la gestion des accès mémoire dans le pipeline. Avant de poursuivre, faisons un rappel rapide sur les dépendances de données. Il faut distinguer deux types de dépendances de données : les dépendances de registres (deux instructions manipulent le même registre), et les dépendances d'adresse (deux instructions manipulent la même adresse). Deux instructions ont une dépendance d'adresse si elles écrivent ou lisent la même adresse mémoire. Des instructions mémoires qui lisent/écrivent des adresses différentes sont indépendantes. Lorsque deux instructions mémoire lisent/écrivent à la même adresse, on parle d''''aliasing mémoire'''. Formellement, l'aliasing mémoire n'est ni plus moins que l'application des dépendances de données aux instructions mémoire. Et comme les autres dépendances de données, il existe plusieurs types d'aliasing mémoire : RAR, RAW, WAR et WAW. Les dépendances RAR ne posent aucun problème, les lectures peuvent changer d'ordre sans aucun problème, tant qu'il n'y a pas d'écritures entre les deux. Par contre, les autres dépendances imposent trois contraintes. * Les dépendances RAW imposent que les lectures ne doivent pas s'exécuter avant une écriture à la même adresse. * Les dépendances WAR imposent que l'on ne peut pas déplacer une écriture avant une lecture à la même adresse. * Enfin, les dépendances WAW font que l'ordre des écritures doit être celui du programme : impossible de changer l'ordre de deux écritures. Les trois contraintes peuvent s'implémenter de plusieurs manières différentes. Comme pour les dépendances de données normales, seules les dépendances RAW sont de vraies dépendances de données. Les deux autres dépendances viennent du fait que l'on utilise la même adresse pour stocker des valeurs différentes. Détecter les cas d'''aliasing'' mémoire est primordial pour gérer la désambiguïsation mémoire, et cela demande de comparer des adresses. S'il existe des techniques similaires au renommage de registre permettent de les faire disparaitre, elles sont très limitées. Diverses optimisations permettent d'émettre des accès mémoire dans le désordre, exactement comme l’exécution dans le désordre le fait pour les autres instructions. Elles utilisent pour cela des ''schedulers'' spécialisées dans les accès mémoire. Il faut vraiment insister sur le fait que l'exécution dans le désordre proprement dite ne se préoccupe pas des micro-opérations mémoire. Pour faire la distinction, on parle de '''désambiguïsation mémoire''' (''memory disambiguation'') pour parler de l'exécution dans le désordre des accès mémoire. Le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. La raison est que la donnée lue est utilisée par d'autres instructions, dépendantes de la lecture. Elles sont donc à la tête d'une chaine de dépendances de données qui peut bloquer le pipeline si elle n'est pas résolue très tôt. Dès qu'une lecture peut être lancée, elle accède directement au cache de données. Par contre, si l'''aliasing'' ne le permet pas (écriture à la même adresse pas encore effectuée) ou que l'adresse à lire n'a pas encore été calculée, la lecture attend son tour. Le problème est que la désambiguïsation mémoire demande de comparer des adresses, pour détecter les dépendances d'adresse. Et cela pose de sérieuses contraintes pour son implémentation. L'exécution dans le désordre normale compare des registres, ce qui est beaucoup plus simple. Les registres utilisés sont directement encodés dans l'instruction elle-même, ce qui fait qu'on peut détecter les dépendances de registre après le décodage. Et elles peuvent même être éliminées par le renommage de registre pour les dépendances WAW et WAR. Par contre, les adresses ne sont pas encodées dans l'instruction elle-même (sauf avec le cas particulier de l'adressage absolu), mais sont lues depuis les registres ou calculées par une unité de calcul. Les adresses ne sont donc connues qu'une fois que l'instruction a attendu suffisamment de temps dans le ''scheduler''. ==L’émission dans l'ordre des accès mémoire== Avant toute chose, faisons quelques rappels sur l'exécution dans le désordre "normale", sans désambiguïsation mémoire. Les micro-opérations renommées sont mises en attente dans un ''scheduler'', le temps que leurs opérandes soient disponibles. Les µops mémoire font cependant exception. ===La file de µops mémoire=== Avec l''''émission dans l'ordre des accès mémoire''', le processeur émet les accès mémoire dans l'ordre du programme. Cela implique que l'unité mémoire n'a pas de ''scheduler'', ni de station de réservation associée. À la place, les micro-opérations mémoire sont placées dans une file de micro-opération dédiée, appelée la '''file de µops mémoire'''. La file de µops mémoire est une mémoire FIFO, dont chaque entrée peut accueillir une µops mémoire. [[File:Unité de gestion des accès mémoires dans l’ordre.png|centre|vignette|upright=2.0|Unité de gestion des accès mémoires dans l’ordre]] Par contre, le processeur utilise des ''schedulers'' pour les unités de calcul. Autant les accès mémoire se font dans l'ordre, autant les instructions arithmétiques/logiques/branchements/autres sont exécutées dans le désordre. En clair, le processeur supporte l'exécution dans le désordre, sauf pour les micro-opérations mémoire. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] Vu de l'extérieur, il faut attendre qu'un accès mémoire soit terminé pour lancer le suivant. Avant d'émettre une micro-opération mémoire, la file de micro-opération (son ''scoreboard'') vérifie si l'unité d'accès mémoire est libre ou non. Si elle est occupée, un accès mémoire est en cours, et on ne peut pas émettre de micro-opération mémoire. Une fois l'accès mémoire terminé, la mémoire ou le cache envoient un signal pour indiquer que l'accès est fini, et c'est au tour d'une nouvelle micro-opération mémoire. L'unité mémoire a un port sur lequel on envoie la micro-opération émise, que ce soit une écriture ou une lecture. Une file de µops implique la ''lecture après émission'', à savoir que les µops mémoire quittent la file de µops, avant de lire les registres adéquats. Il y a donc une connexion entre banc de registre et unité mémoire, comme sur n'importe quel autre processeur. Mais la file de µops n'est pas connectée au banc de registre, ni au réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] L'organisation précédente laisse penser que les calculs d'adresse sont forcément réalisés dans l'unité mémoire. Mais ce n'est pas une obligation. Il est parfaitement possible de scinder une µops mémoire en deux : une µop mémoire proprement dite et une µop de calcul d'adresse. La liaison entre les deux doit cependant se faire en utilisant un registre. L'adresse calculée est enregistrée dans un registre d'adresse "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. ===La file d'écriture : une FIFO de mise en attente des écritures=== L'unité mémoire exécute une µops mémoire à la fois. Avec un détail particulier : les écritures doivent attendre que toutes les instructions se terminent, avant de pouvoir s'exécuter. Sans ça, les exceptions précises sont impossibles, la prédiction de branchement l'est aussi et l'exécution dans le désordre aurait des conséquences fâcheuses (en exécutant des écritures en avance). Précisément, les écritures doivent respecter deux contraintes : s'exécuter dans le tout dernier étage du pipeline, s'exécuter dans l'ordre du programme. Il serait possible de bloquer l'émission des écritures tant que le ROB ne donne pas son accord. Mais ce n'est pas du tout ce qui est fait. A la place, les écritures sont mises en attente dans une mémoire FIFO, appelée la '''file d'écriture''', ou encore la ''Write Queue''. Les écritures sont mises en attente, jusqu'à ce que le ROB leur donne le okay pour transfer l'écriture en mémoire RAM. Nous en avions déjà parlé dans le chapitre sur le pipeline, dans la section sur les exceptions précises. Mais quelques rappels ne feront pas de mal. L'écriture est insérée dans la file d'écriture lorsqu'elle est émise, elle en sort à l'étage de ''Writeback'' si tout se passe bien. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente, faites à tort. De plus, la file d'écriture est une mémoire FIFO, qui mémorise les écritures dans l'ordre du programme. En effet, les écritures sont ajoutées dans la file d'écriture lorsqu'elles sont émises, et elles sont émises dans l'ordre du programme. [[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Write Queue sur un pipeline fixe]] La file d'écriture garantit que les écritures sont dans l'ordre du programme. Et cette propriété élimine totalement les dépendances WAW. Le fait que les écritures se font à la fin du pipeline élimine quant à elle les dépendances WAR. Il ne reste plus qu'à gérer les dépendances RAW, celles où une lecture lit une donnée écrite par une autre instruction. Une telle situation survient, car les écritures sont mises en attente pendant un certain temps, la donnée écrite peut être lue pendant ce laps de temps. La lecture ne peut pas lire la donnée dans le cache, car la donnée à lire n'y est pas encore enregistrée. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire, mais il existe. Il se manifeste surtout sur les processeurs avec peu de registre, où des données doivent régulièrement être déplacées temporairement en mémoire RAM. Pour éviter cela, une solution consiste mettre en attente les lectures si une écriture est en attente. Les lectures sont maintenues dans la file de µops tant que la file d'écriture n'est pas vide. La file de µops mémoire est suivie d'un circuit de détection des dépendances, un '''''scoreboard'' mémoire''' qui est assez simple. L'unité mémoire fournit un bit qui indique si la file d'écriture est vide ou non, ainsi qu'un second bit qui indique qu'elle est vide. Les deux ne sont pas compliqués à générer, ils font partie intégrante de l'interface de toute mémoire FIFO digne de ce nom. Le ''scoreboard'' mémoire utilise ces deux bits pour savoir s'il peut émettre une micro-opération mémoire. Il n'émet pas de lecture tant que la file d'écriture n'est pas vide. Il peut par contre émettre des écritures tant qu'elle n'est pas pleine. Une meilleure solution serait d'interdire les lectures, mais seulement en cas de dépendance mémoire détectée. Et c'est là qu'intervient la technique du '''''load bypassing'''''. Elle autorise une lecture, si elle n'a aucune dépendance avec les écritures mises en attente dans la file d'écriture. Elle doit être mise en attente dans le cas contraire. Pour l'implémenter, il faut comparer l'adresse à lire avec toutes les adresses dans la file d'écriture. La lecture est exécutée immédiatement si aucune correspondance n'est trouvée, mais elle est mise en attente dans le cas contraire. Pour cela, la file d'écriture est modifiée et devient un mélange entre FIFO et mémoire associative. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'' : la lecture doit être bloquée. Sinon, c'est un défaut de ''Write Queue'', la lecture accède au cache, là où se trouve la donnée à lire. Dans le chapitre sur le contournement, nous avons parlé d'une forme de contournement qui corrige ce problème. Les lectures peuvent lire la donnée depuis la file d'écriture si besoin, ce qui renvoie la donnée adéquate. Pour cela, les lectures consultent à la fois le cache et la file d'écriture, comme dit plus haut. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'', ou encore de '''réacheminement écriture-vers-lecture'''. [[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]] La lecture est mise en attente dans l'unité mémoire, vu que c'est elle qui fait les comparaisons d'adresse. Du point de vue extérieur, le ''scoreboard'' mémoire ne voit pas s’il y a eu succès dans la file d'écriture ou non. Il voit juste que l'unité mémoire est occupée et ne peut pas accepter de nouvelle lecture. Elle reste occupée durant quelques cycles, en cas de défaut de ''Write Queue'', le temps de faire la lecture dans le cache. Par contre, en cas de succès de ''Write Queue'', elle reste occupée tant que la lecture n'a pas donné de résultat, ce qui inclue le temps de mise en attente. Les processeurs haute performance implémentent tous le ''Store to load Forwarding'', mais quelques processeurs basse performance ne le font pas. Ils se contentent du ''load bypassing'', qui offre des performances correctes pour un cout en matériel bien plus modeste. Pour donner un exemple, la micro-architecture Jaguar d'AMD était dans ce cas. Elle a été utilisée sur les consoles de jeu de 8ème génération, comme la Xbox One et la Playstation 4. Les processeurs haute performance préférent utiliser plus de circuits pour implémenter le ''Store to load Forwarding'', mais l'implémentation n'est souvent pas complète. En effet, l'implémentation est souvent simple et ne prend en compte que des lectures/écritures à des adresses identiques, sans compter que la taille des données doit aussi être identique. Une écriture d'un entier de 32 bit sera contournée et envoyée à une lecture de 32 bits à la même adresse. Mais la lecture lit seulement 16 bits sur les 32 écrits, le contournement peut ne pas marcher. Concrètement, ce cas précis fonctionnera. Mais le cas inverse, avec une lecture lisant plusieurs écritures de taille plus petite, ne marchera pas sur tous les processeurs. Ne parlons pas du cas où les données écrites sont à cheval sur deux lignes de cache. ==L'exécution anticipée des calculs d'adresse== L'exécution dans le désordre fonctionne mieux si les lectures sont exécutées le plus tôt possible. Les lectures lisent des données qui sont utilisées par d'autres instructions. Si la lecture tarde, les autres instructions sont mises en attente. Exécuter les lectures le plus tôt possible est donc crucial. Mais comment faire ? Un moyen pour ça est de faire en sorte que les adresses à lire/écrire soient connues le plus tôt possible. Mais cela implique que les calculs d'adresse ne sont pas fait en même temps que l'accès mémoire. Pour cela, il est possible de séparer les µops mémoire en deux micro-instructions : une pour le calcul d'adresse et l'autre pour les accès mémoire. On parle de '''calcul anticipé des adresses'''. Avant de poursuivre, un minimum de terminologie. Nous parlerons d'AGU pour désigner les unités de calcul d'adresse, ce qui est une abréviation de ''Adresse Generation Unit''. Nous parlerons aussi de '''µops AGU''' pour parler des µops de calcul d'adresse. Le terme est de moi, il est assez parlant : "µops exécutés dans les AGUs". Nous utiliserons ce terme pour les séparer des µops entières classiques, comme les additions/soustractions/multiplications arithmétiques, les décalages/rotations, etc. Le calcul anticipé des adresses est utilisé avec ou sans désambiguïsation mémoire. Elle a des avantages avec et sans. Sans, elle calcule les adresses en avance, ce qui fait que des lectures/écritures mises en attente ont leurs opérandes prêtes plus rapidement. Avec, cela permet de connaitre les adresses en avance, ce qui permet de détecter plus rapidement les dépendances d'adresse. Mais laissons cela pour plus tard. ===La ''Load/Store Queue'' : une file de µops améliorée=== Implémenter cette technique demande de faire deux choses. Premièrement, il faut faire les calculs d'adresse en-dehors de l'unité mémoire, soit dans une ALU entière, soit dans une AGU dédiée. Deuxièmement, les adresses calculées doivent être mises en attente quelque part, et être liées facilement à la µop mémoire associée. Pour le second point, la file de µops mémoire parait être un bon candidat, mais n'est pas exactement la solution. Une file de µops au sens strict ne mémorise pas les opérandes des µops, donc pas d'adresse. Cela implique la lecture après émission, ce qui veut dire que l'adresse à lire/écrire est lue depuis les registres une fois que la µops sort de la file de µops (idem pour la donnée à écrire). En pratique, cette implémentation rend difficile de faire les calculs d'adresse ailleurs que dans l'unité mémoire. Heureusement, il est possible d'adapter la file de µops pour gérer la lecture avant émission. L'idée est qu'elle mémorise, pour chaque µop mémoire, l'adresse à lire/écrire et d'autres informations. Il ne s'agit donc plus vraiment d'une file de µops mémoire mais quelque chose d'autre, une sorte d'intermédiaire entre une station de réservation et une file de µops. D'un côté, elle fonctionne comme une file de µops car elle émet les µops dans l'ordre du programme. De l'autre, elle mémorise les opérandes des µops mémoire, ce qui en fait une pseudo-station de réservation. Une telle structure est appelée une '''''Load/Store Queue''''' (LSQ). Une LSQ contient plusieurs entrées, chaque entrée pouvant accueillir une µops mémoire et ses opérandes. Une entrée contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures. Les deux derniers champs sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. Elle contient aussi des bits de validité, qui indique si l'adresse ou la donnée sont disponibles, ou si le champ associé est vide. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2|Load Store Queue unifiée]] La LSQ doit faire le lien entre une adresse calculée par l'AGU, et l'entrée associée de la LSQ. Pour cela, l'unité d'émission associe un '''identifiant LSQ''' à une µops mémoire. L'identifiant est généré à l'émission. Aussi bien la µops de calcul d'adresse que a µop mémoire ont toutes deux le même identifiant. L'identifiant est propagé avec la µop de calcul d'adresse, comme tout signal de commande. En sortant de l'AGU, l’identifiant est transmis à la LSQ en même temps que l'adresse. La LSQ regarde quelle entrée a cet identifiant, et mémorise l'adresse dedans. La gestion des données à écrire se fait elle avec le système de contournement normal. La LSQ sait dans quel registre se trouve la donnée à écrire, elle surveille juste le système de contournement pour détecter quand la donnée est disponible. ===Le contournement des adresses mémoire avec une LSQ=== Avec une LSQ, les registres sont lus quand la µop mémoire est insérée dans la LSQ, si elles sont disponibles. Si elles ne sont pas encore disponibles, elles seront envoyées par le système de contournement plus tard. Cela implique qu'il y ait un système de contournement, qui relie les ALUs entières à la LSQ. Les ALUs envoient leur résultat à la LSQ, qui place alors l'adresse calculée dans la bonne entrée, la bonne "µop". [[File:Load-Store Queue.png|centre|vignette|upright=2|Load-Store Queue]] Une autre solution utilise des AGU spécialisées. L'avantage est que cela simplifie grandement le réseau de contournement : seules les AGU sont connectées à la LSQ. Un autre avantage est lié à l'adressage de la destination. En utilisant des ALU entières, il faut préciser que le résultat est à destination de la LSQ, pour configurer le réseau de contournement. Avec des AGU,, l'adresse calculée est envoyée automatiquement à la LSQ, pas besoin de préciser la destination. [[File:ALU et LSQ.png|centre|vignette|upright=2|AGUs et Load-Store Queue.]] Dans les deux cas, les µops AGU sont alors considérées comme des opérations entières un peu spéciales, gérées par le système d'exécution dans le désordre. Elles peuvent être émises dans le désordre, mais la LSQ se chargera de faire les accès mémoire dans l'ordre. Il y a donc calcul des adresses dans le désordre, mais accès mémoire dans l'ordre. ==Les ''schedulers'' mémoire unifiés== Avec la LSQ précédente, les µops mémoire s'exécutent dans l'ordre. Il arrive que des lectures prêtes soient mises en attente, parce qu'une lecture précédente attend ses opérandes. Et ce retard sur les lectures se répercute sur les instructions lecture-dépendantes, donc sur le reste du pipeline. Mais avec l'implémentation qui va suivre, il est possible exécuter les lectures dans le désordre tant que les conditions d'''aliasing'' sont respectées. L'implémentation améliore la LSQ pour en faire un ''scheduler'' spécialisé dans les µops mémoire. Il sera appelé le '''''scheduler'' mémoire''' dans ce qui suit, mais il est souvent appelé la LSQ dans certains documents. [[File:Processeur avec émission dans le désordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans le désordre des accès mémoire]] Lorsqu'une micro-opération mémoire est émise, elle est insérée dans le ''scheduler'' mémoire. Si l'adresse à lire/écrire est disponible, elle est immédiatement lue depuis les registres, idem pour une éventuelle donnée à écrire. Sinon, la micro-opération attend dans le ''scheduler'' mémoire que son adresse soit calculée par le processeur. ===Les calculs d'adresse avec un ''scheduler'' mémoire=== Le ''scheduler'' mémoire détecte des dépendances d'adresse entre instructions. Pour cela, il compare toutes les adresses à lire/écrire. Il mémorise donc les dites adresses, ce qui en fait une station de réservation. Et cela implique que ces adresses ont été calculées ailleurs, dans une ALU entière, ou dans une AGU séparée de l'unité mémoire. L'implémentation la plus simple effectue les calculs d'adresse dans les ALU entières. La sortie de toutes les ALU entière est reliée au ''scheduler'' mémoire. [[File:Contournement pour la file de µops mémoire.png|centre|vignette|upright=2|''scheduler'' mémoire]] Une autre implémentation utilise des unités de calcul d'adresse dédiées, afin de simplifier le réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse via AGU.png|centre|vignette|upright=2|ALU et ''scheduler'' mémoire]] Une conséquence est que l'implémentation impose que toutes les instructions mémoire sont découpées en deux µops : une µop pour calculer l'adresse, et une µop mémoire. C'est un défaut assez important, car une instruction mémoire prendra deux places dans le ROB, dans les stations de réservation, etc. Vu que les instructions mémoire sont très fréquentes, la capacité du ROB/autres est alors fortement réduite. Il faut noter que les adresses calculées dans l'ALU/AGU doivent atterrir dans l'entrée adéquate, celle de la µops qui doit recevoir l'adresse. La seule solution est que le ''program counter'' de la µops est transmis dans le pipeline, propagé avec la µops de calcul d'adresse. Quand le résultat est calculé, le ''program counter'' associé est envoyé avec au ''scheduler'' mémoire. Le ''scheduler'' mémoire enregistre alors l'adresse dans l'entrée qui est taguée avec cette adresse. ===La détection des dépendances mémoire=== Le ''scheduler'' mémoire intègre un système de détection des dépendances, qui est bien différent de celui d'une station de réservation normale. Les ''schedulers'' mémoire comparent non pas des noms de registres, mais des adresses/opérandes. Pour détecter les dépendances, le ''scheduler'' mémoire compare les adresses à lire/écrire. Pour les µops dont l'adresse est inconnue, le cas dépend de l'implémentation. La version la plus conservatrice suppose qu'elle a des dépendances possibles avec toutes les autres. Chaque micro-opération en attente compare son adresse avec celle de toutes les micro-opérations précédentes. Ou presque, la comparaison ne doit tenir compte que des écritures, et de celles situées avant dans l'ordre du programme. Le ''scheduler'' mémoire doit donc mémoriser les instructions dans l'ordre du programme, tout en faisant des comparaisons d'adresse. Ça en fait une sorte hybride entre mémoire associative et mémoire FIFO. Nous avions vu dans le chapitre "L'exécution dans le désordre" qu'il y a plusieurs manières pour implémenter une station de réservation : avec une mémoire associative, avec préplanification, avec des matrices de dépendances. Le problème est qu'on ne peut pas utiliser une mémoire associative pour le ''scheduler'' mémoire, qui utilise des comparateurs pour comparer les noms de registres/adresses. En effet, la détection des dépendances mémoire demande de faire beaucoup de comparaisons : chaque adresse est comparé à toutes les autres. Et les adresses faisant facilement 32 à 64 bits, les comparateurs sont bien plus imposants que ceux qui comparent des noms de registres de 5-10 bits. Le cout en matériel serait trop élevé, de même que le temps de calcul des dépendances. À la place, on utilise des matrices de dépendances. La détection des dépendances mémoire utilise précisément deux matrices de ce type : une matrice de disponibilité, et une matrice de dépendance. Ces matrices forment une espèce de tableau carré, organisé en lignes et en colonnes. L'instruction dans la énième entrée se voit attribuer la énième ligne et la énième colonne. À l'intersection d'une ligne et d'une colonne, on trouve un bit qui indique si l'instruction de la ligne et celle de la colonne ont une dépendance. La '''matrice de disponibilité''' permet de bloquer l'exécution des lectures/écritures tant qu'une instruction précédente n'a pas calculé son adresse, tant qu'on ne peut pas vérifier les dépendances. Lorsqu'une instruction est ajoutée dans le ''scheduler'' mémoire, elle met tous les bits de la colonne attribuée à 1 si l'adresse à lire/écrire est inconnue, puis les met à 0 une fois l'adresse calculée. Une instruction ne peut pas démarrer si, sur la ligne qui lui est attribuée, se trouve un 1. Plus précisément, si on trouve un 1 pour les instructions plus anciennes, ce qui fait qu'une partie de la matrice est ignorée. Les bits notés X dans le tableau suivant sont ignorés, car ils précisent la disponibilité des opérandes d'un opérande ultérieure, ce qui est inutile. En clair, les calculs d'adresse modifient les colonnes, et on vérifie les lignes pour autoriser l'exécution. {|class="wikitable" |- ! Instructions mémoire dans la LSB, triées de la plus ancienne à la plus jeune ! 0 !! 1 !! 2 !! 3 !! 4 !! 5 !! 6 |- ! 0 | 1 || X || X || X || X || X || X |- ! 1 | 0 || 0 || X || X || X || X || X |- ! 2 | 0 || 1 || 0 || X || X || X || X |- ! 3 | 0 || 1 || 1 || 1 || X || X || X |- ! 4 | 0 || 0 || 0 || 0 || 1 || X || X |- ! 5 | 0 || 0 || 1 || 0 || 0 || 1 || X |- ! 6 | 1 || 1 || 1 || 1 || 1 || 1 || 0 |} Les '''matrices de dépendances''' fonctionnent sur le même principe, mais déterminent les dépendances mémoire. Lorsque le processeur a calculé une adresse, il la compare avec les adresses à lire/écrire déjà dans le ''scheduler'' mémoire. Il met alors à jour les bits de la colonne de la matrice de dépendance : le bit à l'intersection d'une ligne et d'une colonne est mis à 0 si les deux instructions n'ont pas de dépendances, à 1 si elles en ont une. Une instruction mémoire est exécutée si tous les bits de sa ligne sont à 0. Là encore, on ne tient compte que des bits pour les instructions plus anciennes, la matrice a encore une fois une forme triangulaire, comme pour la matrice de disponibilité. Une optimisation scinde le ''scheduler'' mémoire en deux structures, aux rôles légèrement différents. La première gère la disponibilité des opérandes, la seconde gère seulement les dépendances mémoire. La première est une station de réservation où les instructions sont mises en attente tant que leurs opérandes ne sont pas prêts. La seconde met en attente des micro-opérations dont les opérandes sont prêts, mais qui doivent attendre pour une autre raison : dépendance mémoire, cache occupé, etc. L'avantage est qu'on remplace une grosse station de réservation par deux plus petites, mais l'intérêt est surtout relié au système de détection des dépendances. Sans cette optimisation, on a deux matrices énormes reliées à une ''Load/Store Queue'' unique. Avec la séparation, la matrice de disponibilité est reliée uniquement à la première station de réservation, alors que la matrice de dépendance est reliée uniquement à l'autre. Les deux matrices sont donc plus petites, car reliées à deux stations de réservation mémoire plus petites. ==L'exécution spéculative des accès mémoires== Pour rappel, le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. Les données lues sont en effet, le départ d'une série d'instructions dépendantes, généralement des instructions arithmétiques, mais parfois d'autres lectures. Quand on manipule des structures de données, il n'est pas rare qu'une lecture lise un pointeur-adresse utilisé par une autre lecture. Les techniques de désambiguïsation mémoire que nous avons vu précédemment sont dites conservatrices, à savoir qu'elles ne changent pas l'ordre de deux instructions mémoire si elles ont une dépendance d'adresse. Elles exécutent une lecture le plus tôt possible, mais seulement si on sait que celle-ci n'aliase pas d'écritures précédentes. Et cela demande de connaitre les adresses des écritures précédentes dans l'ordre du programme. Si une lecture est précédée par une ou plusieurs écritures, les adresses à écrire doivent être connues. Il se peut en effet qu’une écriture dont on ne connait pas l'adresse aliase la lecture. La garantie est liée à la présence de la file de µops mémoire ou la file de µops STORE. Si une seule écriture a une adresse inconnue, elle reste dans la file de µops mémoire et bloque l'émission de toutes les autres micro-opérations mémoire, lecture comme écriture. Mais il existe des techniques dites spéculatives, qui modifient l'ordre des accès mémoire sans se soucier de leurs dépendances. Elles émettent les lectures dès que leurs opérandes sont prêts, sans se préoccuper de l'aliasing avec une écriture précédente. Pour éviter tout problème, le processeur vérifie si aucune dépendance mémoire n'est violée et corrige le tir dès qu'une violation est détectée. Le processeur vérifie s'il a fait une erreur de prédiction, en vérifiant les adresses à écrire ou lire. En cas d'erreur, le processeur fait comme lors d'une prédiction de branchement ratée : il remet le pipeline en ordre et ré-exécute les accès mémoire dans le bon ordre. Il s'agit d'un cas particulier d’'''exécution spéculative''', au même titre que la prédiction de branchement. Toutes les techniques de ce genre exécutent des lectures dans le désordre. L'avantage est que cela permet d'exécuter les lectures en avance. Vu que la donnée lue est l'opérande d'autres instructions, les exécuter en avance fait prendre de l'avance à un paquet d'instructions. Mais il faut comparer cet avantage au risque de mauvaise prédiction. Et aussi bizarre que cela puisse paraître, cela donne de bons résultats. Il faut dire que les situations où une lecture relit une donnée tout juste écrite sont rares : même pas 2 à 3 % des lectures. Les dépendances mémoire sont donc très rares, les cas d'''aliasing'' sont vraiment limités. En termes de cout en transistors, le hardware qui vérifie les dépendances mémoire reste globalement le même à peu de choses près, il est juste déplacé dans le pipeline et adapté à sa nouvelle fonction. ===La file de lectures=== La technique que nous allons voir émet les lectures dans l'ordre, ce qui simplifie énormément la gestion des dépendances. Les lectures sont émises dans l'ordre, les écritures aussi. Par contre, l'ordre entre lectures et écritures n'est pas respecté. Une lecture peut passer avant une écriture sans problèmes, voire avant plusieurs écritures d'un seul coup. Une telle lecture exécutée en avance d'une autre écriture est appelée une '''lecture spéculative'''. Une lecture spéculative peut donc sauter devant une ou plusieurs écritures, qui seront appelées des '''écritures sautées'''. Une écriture sautée est une précédant une lecture spéculative, dans l'ordre du programme. Évidemment, il se peut qu'une écriture sautée écrive la donnée qui est lue par la lecture spéculative. Il y a alors une violation de dépendance mémoire. Mais le processeur suppose qu'il n'y a pas de dépendantes de ce type et émet la lecture en avance. Rassurez-vous, il surveille quand même la situation, pour tout annuler ensuite s'il détecte une violation de dépendance mémoire. J'ai dit plus haut que le processeur émet les lectures dans l'ordre, ainsi que les écritures dans l'ordre. Pour cela, la LSQ est scindée en deux files de µops : une pour les µops LOAD et une autre pour les µops STORE. Les cours d'architecture des ordinateurs nomment souvent ces deux files en utilisant les termes de ''file de STORE'' (''store queue'') et de ''file de LOAD'' (''load queue''), mais il y a un risque de confusion avec la file d'écriture vue précédemment, et la file de lecture qu'on introduira dans la suite. Dans ce qui suit, nous parlerons de '''file de µops LOAD''' et de '''file de µops STORE''', pour éviter toute confusion. Les deux files sont chacune connectées à l'unité mémoire, via un port dédié. L'unité mémoire a donc un port pour les lectures, et un port pour les écritures. Le port d'écriture est relié à la file d'écriture, le port de lecture est directement relié au cache de données. Il faut noter qu'une unité mémoire à deux ports n'est pas si bizarre que cela. En effet, le cache de données a lui aussi deux ports, dans la majorité des cas, avec un port de lecture et un port d'écriture. [[File:Load Ordering, Store Ordering.jpg|centre|vignette|upright=2|Exécution dans le désordre des lectures.]] Maintenant, voyons comment sont détectées les violations de dépendances mémoire. Les dépendances qui peuvent être violées dans ce cas de figure se font entre une écriture sautée et une lecture spéculative. Si une lecture spéculative lit une donnée qui est écrite par une écriture sautée, alors une dépendance RAW a été violée. Pour les détecter, le processeur garde une trace des lectures spéculatives; tant que les instructions précédentes ne sont pas terminées. Pour cela, il utilise une file d'attente, qui mémorise les lecture spéculatives. Nous l'appellerons la ''file de vérification des lectures'', ou encore la '''file de lectures'''. Elle est une sorte d'équivalent de la file d'écriture, mais pour les lectures. Les lectures sortent de la file µops LOAD dès que leurs adresses sont calculées, sans tenir compte des dépendances mémoires. Elles sont alors ajoutées à la file de lectures et s'exécutent immédiatement, de manière spéculative. Pour vérifier qu'il n'y a pas d'erreur de spéculation, le processeur conserve l'adresse de la lecture effectuée dans la file de lectures. Les lectures quittent la file de lecture quand toutes les instructions précédentes dans l'ordre du programme sont terminées, à savoir tant quand elles ont quitté le tampon de réordonnancement/ROB. Notons que la file de vérification des lectures contient les lectures dans l'ordre du programme. En effet, les lectures sont accumulées en sortant de la file de µops LOAD, où elles sont triées dans l'ordre du programme. Et cela permet de gérer les erreurs de prédiction parfaitement, en annulant les instructions suivant une erreur de prédiction. [[File:Spéculation avec une file de lecture.jpg|centre|vignette|upright=2.5|Spéculation avec une file de lecture.]] Reste à détecter les erreurs de prédiction, ce qui peut être fait avec deux méthodes. La première détecte les violations de dépendances mémoire à chaque écriture. L'idée est que chaque écriture vérifie si une lecture anticipée n'a pas lue la donnée écrite en avance. Pour cela, quand une écriture est émise, elle consulte la file de lecture pour voir s'il y a un conflit d'adresse. La vérification fait cependant gaffe à ne prendre en compte que les lectures situées après l'écriture dans l'ordre du programme. Précisément, elle envoie l'adresse à écrire à la file de lecture et cherche si une lecture ultérieure a eu lieu à la même adresse. Si c'est le cas, cela signifie qu'une lecture anticipée aurait du lire la donnée actuellement écrite, mais que la lecture a été exécutée trop en avance. La lecture n'a pas lu la bonne donnée, il y a eu une violation de dépendance mémoire. Pour faire cette vérification, la file de lecture est un mélange entre FIFO et cache/mémoire associative, sur le même modèle que la file d'écriture. La différence est que la consultation de la file de lecture ne sert pas à faire du contournement, mais à détecter les dépendances. Quand une dépendance est détectée, l'écriture se fait normalement. Par contre, la lecture marquée comme invalide dans la file de lecture. L'exécution du programme se poursuit, jusqu'à ce que la lecture arrive à l'étape de ''commit'', à savoir que le ROB lui dit que les instructions précédentes sont terminées sans encombre. Là, la lecture étant invalide, le processeur déclenche une procédure de réparation, qui vide partiellement le pipeline de toutes les instructions qui suivent la lecture. La lecture est de plus ré-executée, afin de lire la bonne donnée. Avec la seconde méthode, les violations de dépendance mémoire sont détectées au moment où la lecture quitte à la file de lectures et le ROB. L'idée est de comparer la donnée lue de manière spéculative avec la donnée lue de manière non-spéculative. Pour faire la comparaison, la lecture spéculative copie la donnée lue dans la file de lecture. Quand la lecture quitte la file de lecture et le ROB, la lecture est ré-exécutée et la donnée relue. Le processeur compare alors la donnée lue à ce moment avec la donnée mémorisée dans la file de lecture. Si la donnée est différente, alors il y a eu erreur de violation de dépendance mémoire. Évidemment, relire une donnée n'est pas gratuit. Il est possible de limiter son impact en performance en dédiant un port de lecture sur le cache rien que pour ça. Mais le cout en circuit est comparable à l'usage d'une FIFO associative de la technique précédente. Mais quoi qu'il en soit, le cache doit permettre des lectures en un seul cycle, sans quoi la méthode ne marche pas vraiment. ===La prédiction de dépendances mémoires=== Certains processeurs essayent de prédire si deux accès mémoires sont dépendants : ils incorporent une unité qui va fonctionner comme une unité de prédiction de branchement, à la différence qu'elle prédit les dépendances mémoires. Si cette unité prédit que deux accès mémoires sont indépendants, le processeur les exécute dans le désordre, et les exécute dans l'ordre du programme dans le cas contraire. Il faut prendre en compte les erreurs de prédiction, ce qui est fait comme pour les mauvaises prédictions de branchement : on vide le pipeline. Beaucoup de techniques de prédiction des dépendances mémoire ont été inventées et en faire une revue exhaustive serait long et fastidieux. On pourrait citer les ensembles colorés (''color sets''), et bien d'autres techniques. Mais nous n'allons parler que des techniques les plus élémentaires. Quoi qu’il en soit, la recherche sur le sujet est assez riche, comme toujours quand il s'agit d'architecture des ordinateurs. On peut améliorer cette technique en mémorisant les instructions qui ont causé une mauvaise prédiction de dépendance mémoire. La prochaine fois qu'on exécute ces instructions, on sait qu'il y a de grandes chances qu'il se produise une erreur de prédiction, ce qui pousse à ne pas les exécuter de manière spéculative. On peut pour cela réutiliser les techniques de prédiction de branchement, tels des compteurs à saturations, mis à jour en cas d'erreurs de prédiction de dépendances mémoire. Dans tous les cas, on trouve un cache, équivalent au ''branch target buffer'', qui mémorise les instructions fautives (leur ''Program Counter''), avec d'autres informations comme les adresses des instructions dépendantes. La première classe de techniques du genre consiste à mémoriser les lectures qui ont causé une violation de dépendance, tandis que l'autre ne mémorise que les écritures. Cette dernière est l'approche utilisée par le '''cache de barrières d’écriture''' (store barrier cache). Enfin, nous allons conclure avec une dernière technique : celle des '''ensembles d’écritures''' (store sets). Cette technique est capable de gérer le cas où une lecture dépend de plusieurs écritures : le processeur mémorise l'ensemble des écritures avec lesquelles la lecture a déjà eu une dépendance. Cet ensemble d'écritures associées à une lecture est appelé tout simplement un ensemble d’écritures, et reçoit un identifiant (un nombre) par le processeur. Cette technique demande d'utiliser deux tables : * une qui assigne un identifiant d’ensemble d’écritures à l'instruction en cours ; * une autre qui mémorise les ensembles d’écritures sous la forme d'une liste chainée : le début de la liste correspond à l'écriture la plus ancienne de l’ensemble. Cela permet d'obtenir l'écriture la plus ancienne dans cet ensemble d’écritures directement. Quand le processeur détecte une violation de dépendance entre une lecture et une écriture, elle ajoute l'écriture dans l’ensemble d’écritures adéquat. Le déroulement d'une lecture demande d'accéder à la première table pour récupérer l'identifiant de l’ensemble d’écritures, et l'utiliser pour adresser la seconde table. S'il y a dépendance, cet accès renvoie l'écriture fautive en cas de dépendance. Quand une écriture est envoyée à l'unité mémoire, celle-ci va accéder à la table de correspondances de la même manière qu'une lecture, et va récupérer l'identifiant de l’ensemble d’écritures auquel elle appartient, identifiant qui est utilisé pour vérifier s'il y a une écriture en cours d’exécution. Si c'est le cas, cela signifie que l'écriture en cours d’exécution doit s’exécuter avant l'écriture qui a consulté la table : cette dernière est mise en attente. ==La prédiction d'adresse et de valeur== D'autres techniques de prédiction mémoire plus élaborées que les précédentes existent. Il s'agit de la prédiction d'adresse et de la prédiction de valeur. Leur nom trahit ce qu'elles font. La première technique tente de prédire l'adresse d'une lecture/écriture, alors que la seconde tente carrément de prédire quelle sera la donnée lue. Il y a bien de la recherche académique sur le sujet, mais il s'agit de techniques assez osées, dont on voit mal comment elles pourraient fonctionner. L'efficacité de cette technique a été étudiée grâce à des simulateurs. Suivant les études ou les programmes, on trouve des résultats qui varient pas mal. Dans certains cas, la performance baisse un peu, et dans d'autres, on peut avoir des gains de plus de 10% ! Mais dans des cas normaux, on trouve des gains de 4-5% environ. Ce qui est tout de même pas mal à l'heure actuelle. Cependant, en Janvier 2025, à l'heure où j'écris ces lignes, il a été découvert la première utilisation de ces deux techniques dans un processeur commercial. La découverte fait suite à la publication de deux vulnérabilités matérielles sur les CPU Apple M2 et M4 et quelques CPU ARM, qui font justement usage de ces deux techniques. Les deux vulnérabilités, appelées SLAP et FLOP, sont assez similaires aux attaques Spectre et Meltdown, sauf qu'elles attaquent non pas la prédiction de branchement, mais la prédiction d'adresse et de valeur du CPU. ===La prédiction d'adresse=== La prédiction d'adresse tente de prédire l'adresse d'une lecture à l'avance, ce qui permet de lancer les lectures en avance. Si la prédiction est correcte, on gagne quelques cycles qui permet aux unités de calcul de faire leurs calculs plus en avance, sans compter que les mécanismes de désambiguïsation mémoire fonctionnent plus efficacement. Si jamais cette adresse est connue plus tôt, on détecte les dépendances plus rapidement et on peut agir en conséquence. Des variantes de ces unités de prédiction d'adresse sont utilisées pour précharger des données depuis le cache de donnée. Et leur efficacité est assez bonne, voire excellente dans certains cas. La différence est que la prédiction d'adresse est utilisée pour prédire les adresses à lire depuis le cache vers les registres, ce qui est le sujet de cette sous-partie. La prédiction d'adresse est réalisée par une sorte de cache, qui associe à chaque instruction la prochaine adresse à lire. Nous appellerons ce cache la '''table de prédiction d'adresse'''. Pour faire l'association instruction <-> adresse lue/écrite, le tag du cache utilise l'adresse de l'instruction (le ''program Counter''), alors que la ligne de cache associée contient l'adresse prédite. Son contenu est utilisé pour faire une prédiction. Lorsqu'une lecture est émise, la table est consultée pour récupérer en avance l'adresse à lire, avant que celle-ci soit calculée par l'ALU. La lecture est alors exécutée en avance, avant que la vraie adresse soit connue, en utilisant l'adresse prédite. Reste qu'il faut des circuits autour de cette table de prédiction d'adresse, pour déterminer si la prédiction est correcte, ainsi que pour remplir cette table avec une adresse prédite. Pour cela, il y a un circuit de prédiction d'adresse dédié. Il récupère les instructions qui quittent le ROB ou la file des lectures mémoire, afin de vérifier les lectures qui viennent de se terminer/''commit'' et qui quittent le pipeline. Il peut contenir une '''table d'apprentissage''', qui mémorise des informations nécessaires pour faire des prédictions d'adresse. Par exemple, un historique des dernières adresses lues pour les lectures les plus récentes, ou un historique des adresses des lectures. La table est aussi utilisée pour déterminer si les prédictions effectuées étaient correctes ou non. Pour cela, il est possible de réutiliser les techniques vues dans le chapitre sur la prédiction de branchement, notamment des compteurs à saturation. Pour la prédiction de branchement et les autres formes de prédiction mémoire, les table d'apprentissage et pour les instructions/adresses sont fusionnées en une seule. Mais ici, il est nécessaire de les séparer pour une raison assez particulière : éliminer la pollution du cache dans la table de prédiction d'adresse. On ne peut pas allouer une ligne de cache pour chaque nouvelle instruction de lecture exécutée. Seule une minorité des lectures profitent de la prédiction d'adresse. Il est nécessaire de filtrer les lectures pour ne conserver que celles qui profitent de la prédiction d'adresse, ce qui est possible plus facilement en séparant la table d'apprentissage et les compteurs à saturation de la table de prédiction d'adresse. Une première méthode consiste à prédire que la lecture lit toujours la même adresse, ce qui lui vaut le nom de '''prédiction d'adresse constante'''. On pourrait se demander pourquoi une telle situation surviendrait. La raison est qu'il arrive que les compilateurs n'arrivent pas à gérer les accès mémoires efficacement. Dans certaines situations un peu compliquées impliquant des pointeurs ou des références, divers phénomènes complexes d'''aliasing'' des pointeurs peuvent générer des relectures intempestives de données en mémoire. Cela peut aussi arriver lorsqu'on compile du code qui provient de plusieurs librairies, ou quand du code lit régulièrement des constantes en mémoire ROM. La prédiction d'adresse constante ajoute un circuit qui analyse les dernières lectures, une fois qu'elles quittent le ROB, qu'elles ''commit''. Si, pour une instruction de lecture, l'adresse lue est toujours la même, alors l'instruction est ajoutée dans la table de prédiction d'adresse. Vu que les instructions qui accèdent toujours à la même adresse sont rares, il est préférable d'initialiser les compteurs à saturation de façon à ce qu'ils disent que toute nouvelle instruction change d'adresse. Il s'agit d'une méthode simple mais peu efficace, car cette situation est rare. Une autre méthode détecte les accès en enjambées, là encore quand les lectures quittent le ROB. Nous l’appellerons la '''prédiction d'adresse en enjambées'''. De tels accès surviennent régulièrement quand on accède des tableaux ou d'autres structures de données semblables, ce qui rend la technique très intéressante. Implémenter cette technique est facile : il suffit d'ajouter un circuit qui détecte les accès en enjambées, au lieu de celui qui détecte les accès constants. À chaque fois qu'une lecture entraine un succès de cache dans la table de prédiction d'adresse, l'enjambée est ajoutée à l'adresse contenue dans la table de prédiction d'adresse, l'ancienne adresse prédite est remplacée par celle avec l'enjambée d'ajoutée. Sur les Apple M2-M4, la prédiction d'adresse gére aussi bien les adresses constantes que les accès en enjambée. Le brevet qui décrit l'implémentation de l'unité de prédiction d'adresse et de valeur est potentiellement le suivant : [https://patents.google.com/patent/US11829763B2/en Early load execution via constant address and stride prediction]. En théorie, il est possible de faire mieux en repérant des accès mémoires qui se répètent de façon régulière et cyclique, même s'ils n'ont aucune enjambée. Ce genre d'accès se trouve assez souvent lorsque l'on manipule des listes chainées ou des structures de données assez irrégulières comme des arbres, des graphes, etc. Mais il n'y a pas encore de CPU qui implémente de telle optimisation. ===La prédiction de valeur : prédire la donnée lue=== Les techniques de '''prédiction de valeur''' (''Value Prediction'') consistent à prédire quelle est la donnée qui sera lue par une instruction de lecture. Oui, vous avez bien lu : le processeur est capable de parier sur la valeur qui sera chargée depuis la mémoire, de prédire si cette valeur vaut 0, 1, 1024, etc. Une fois son pari fait, il exécute les instructions du programme avec la valeur qu'il a pariée de façon spéculative. Si le pari est correct, le CPU continue l’exécution. Sinon, il est obligé de vider le pipeline et de recommencer avec la bonne valeur. Au premier abord, cette technique semble franchement mal partie tellement les chances de se tromper sont tellement énormes ! Mais le fait est que, dans certains cas, il est possible de spéculer correctement. Là encore, les techniques les plus simples fonctionnent dans deux cas. Le premier est celui où une donnée est relue à l'identique à chaque fois que la lecture est exécutée. Le second est celui où la donnée est incrémentée/décrémentée d'une constante qui est toujours la même d'une exécution de la lecture à l'autre. Une sorte d'enjambée constante, mais pour les données. Nous allons nous concentrer sur le premier cas, celui où une donnée est relue plusieurs fois sans être modifiée entre temps. L'idée de base est qu'une instruction de lecture qui s'exécute plusieurs fois de suite à la même adresse renvoie la même valeur à chaque fois. C'est parfaitement possible s’il n'y a eu aucune écriture à cette adresse entre-temps, d'où le fait qu'il faille surveiller si les prédictions constantes sont correctes. On peut se demander quelles sont les raisons qui font qu'une instruction de lecture renvoie la même valeur à chaque fois. Après tout, autant lire une seule fois la donnée et la garder dans un registre une bonne fois pour toutes ! Mais dans la réalité, on fait face à quelques limites, qui seront détaillées dans la liste suivante. La première est que le manque de registre fait que certaines données sont conservées en RAM et sont relues fréquemment. Cela arrive quand on stocke des constantes en mémoire. Par exemple, sur les processeurs x86, les constantes flottantes ne peuvent pas être intégrées dans nos instructions via le mode d'adressage immédiat. À la place, on les stocke en mémoire et on les charge dans les registres à chaque fois qu'on en a besoin. L'implémentation est similaire à la prédiction d'adresse, si ce n'est que c'est la donnée lue qui est prédite. Là encore, on a une '''table de prédiction de donnée lue''', consultée lorsqu'une lecture est émise. La table de prédiction de la donnée lue est une mémoire cache qui associe le ''Program Counter'' de la lecture à la donnée prédite, éventuellement couplé à des compteurs à saturation. Elle est consultée à chaque fois qu'une lecture est émise, la donnée adéquate est récupérée lors d'un succès de cache. On trouve aussi un circuit qui insère ou retire des instructions de la table de prédiction de donnée lue. Elle détecte les lectures prédictibles, couplé à une table d’apprentissage, et quelques circuits annexes liés au ROB ou aux files de lectures. Elle contient au minimum des compteurs à saturations, associés chacun à une instruction (son ''program counter'' pour être précis). ==L'optimisation du ''Mirroring memory operand''== Les processeurs modernes implémentent une optimisation concernant les accès mémoire. Cette optimisation a été décrite par Agner Fog dans ses manuels d'optimisation, sous le terme de '''''Mirroring memory operand'''''. Il a documenté son apparition sur les processeurs Zen 2 d'AMD, puis son évolution sur les processeurs Zen 4 et 5. L'optimisation est bizarrement absente de la microarchitecture Zen 3. Elle se manifeste dans une condition très précise : plusieurs instructions proches accèdent à la même adresse. Les deux instructions ne sont pas forcément consécutives, il peut y avoir une dizaine ou centaine d'instructions entre les deux. Une telle situation survient souvent sur les CPU CISC, et notamment sur les CPU x86, car ils ont peu de registres. les compilateurs font donc beaucoup d'échanges entre pile d'appel et registres, ce qui fait que des données écrites dans la pile sont souvent relue quelques instructions plus tard. Dans ce cas, la donnée lue/écrite est mémorisée dans un registre interne au processeur et peut être accédée très rapidement. Par exemple, imaginons plusieurs lectures proches à la même adresse, sans écriture entre elles. Dans ce cas, le processeur ne fera qu'une seule lecture en mémoire RAM, les lectures ultérieures liront la donnée depuis le registre interne. Comme autre exemple, imaginons une écriture suivie plus tard par une lecture à la même adresse. Dans ce cas, la donnée sera écrite dans un registre interne, en plus d'être propagée dans le cache ou la RAM, et la lecture ultérieure lira directement le registre interne. Le gain en performance est alors conséquent, comparé à du simple ''store-to-load forwarding''. Il faut noter que la technique marche aussi pour les opérations ''load-op'', pas seulement pour les instructions d'accès mémoire. Par exemple, prenons une écriture en RAM, suivies quelques instructions plus tard par une opération ''load-op'' à la même adresse. L'écriture écrit à une adresse, qui est lue par l'instruction ''load-op''. L'optimisation s'applique : l'instruction ''load-op'' lira l'opérande mémoire depuis un registre interne du processeur, qui contient une copie de la donnée écrite. Sur les processeurs Zen 2, l'optimisation marchait aussi pour les instructions PUSH et POP, qui lisaient/écrivaient dans la pile d'appel. Mais cette possibilité a été retirée sur les processeurs Zen 5. La technique détecte que deux instructions accèdent à la même adresse sous certaines conditions bien précises. L'optimisation ne marche que pour les modes d'adressages indicés et indirect, avec ou sans décalages, mais pas en mode absolu ou relatifs. Il faut préciser que l'optimisation ne marche que pour les registres généraux, pas les registres flottants. Les registres doivent être précisés de la même manière dans toutes les instructions. Par exemple, le processeur ne reconnait pas que l'addition des deux registres RCX + RAX est la même que RAX + RCX. Sur les CPU Zen 2 et 4, elle ne marchait que pour des opérandes de 32 et 64 bits, mais les processeurs Zen 5 gèrent des données de 8 et 16 bits aussi. Les décalages étaient limités à 8 bits sur le Zen 2, mais n'a plus de limites sur les Zen 4 et 5. ==La désambiguïsation mémoire a des effets de bord== La désambiguïsation mémoire a une caractéristique que l'exécution dans le désordre simple n'a pas : elle a des effets qui débordent sur la mémoire. Il est possible de savoir si un processeur fait de la désambiguïsation mémoire en regardant le bus mémoire. On s’aperçoit alors que les lectures se font dans un ordre différent de celui du programme. À l'opposé, l'exécution dans le désordre n'a pas d'effets observables de ce genre. Elle améliore la performance, elle modifie les durées observables des instructions, mais cela se limite à des questions de ''timings''. Le seul moyen de savoir si un processeur fait de l'exécution dans le désordre est de mesurer les latences/durées de chaque instruction. Mais aucun état observable au-delà des ''timings'' n'est modifié. Pas avec la désambiguïsation mémoire. ===La consistance mémoire=== Le fait que la désambiguïsation mémoire ait des effets observables ne pose aucun problème avec un seul processeur, ca elle est conçue pour. Par contre, les techniques de désambiguïsation mémoire peuvent poser des problèmes avec plusieurs processeurs. Il arrive que des programmes partagent une même portion de mémoire, et lisent/écrivent dedans. Avec plusieurs processeurs, chaque processeur effectue la désambiguïsation mémoire dans son coin sans se préoccuper des autres. Les opérations d'écriture peuvent être mises dans le désordre du point de vue des autres processeurs, et les lectures effectuées par les autres processeurs peuvent alors renvoyer de vielles données. Un exemple classique est celui de deux cœurs qui partagent le même cache ou la même mémoire, mais qui utilisent chacun une file d'écriture (''store buffer''). Imaginons que le premier cœur effectue une écriture, puis une lecture, à des adresses différentes. L'écriture est retardée grâce au tampon d'écriture, alors que la lecture lit directement le cache. Pour le premier processeur, l'écriture s'est réalisée avant la lecture, car pour lui, la fin de l'écriture signifie écrire dans le tampon d'écriture. Mais pour le second cœur, l'ordre s'est inversé : il voit la lecture dans le cache, puis l'écriture dans le cache. La raison est qu'il n'a pas connaissance du contenu du tampon d'écriture de l'autre cœur. Un autre exemple : les techniques de prédiction d'adresse et de valeur posent des problèmes avec les dépendances RAW. Elles permettent à une instruction mémoire de s'exécuter avant une autre, malgré la présence d'une dépendance RAW entre les deux. L'ordre entre les deux instructions change, alors que les autres processeurs ne devraient pas le voir. Et dans les faits, les processeurs qui incorporent ces techniques ont des modèles de consistance mémoire qui ne permettent pas ces optimisations en cas de dépendances RAW. Les deux techniques doivent idéalement être désactivées pour les instructions mémoire qui ont des dépendances RAW. Les programmeurs doivent tenir compte de ce genre de situations, dans des cas assez rares et liés à la programmation bas niveau. Pour cela, ils doivent savoir comment le processeur réorganise les accès mémoire. Concrètement, chaque processeur définit un '''modèle de consistance mémoire''' qui indique quelles optimisations sont activées et non. Il s'agit d'une sorte de contrat entre le logiciel et le matériel : les programmeurs savent comment le processeur va réorganiser les lectures et écritures et peuvent coder leurs programmes en fonction. L'implémentation du système de consistance mémoire dépend grandement de l'architecture, mais des modèles ont été standardisés afin de permettre aux programmeurs de savoir où placer les barrières mémoires. Il s'agit de vrais standards, formalisés, parfois mathématiquement, que des chercheurs étudient sur un plan mathématique et algorithmique. Le modèle préféré des programmeurs est la '''consistance séquentielle''', où les différents processeurs effectuent des accès mémoire dans l'ordre du programme et les accès mémoire simultanés sont interdits. La première contrainte interdit l'usage de mécanismes de désambiguïsation mémoire, la seconde interdit l'usage de caches non-bloquants. La consistance séquentielle a donc un cout en performance assez important, ce qui fait que les processeurs modernes n'utilisent pas la consistance séquentielle. Un modèle plus flexible est le '''''total store ordering''''', qui autorise la présence d'une file d'écriture et l'usage de caches non-bloquants pour les lectures (interdit d'avoir plusieurs écritures simultanées). C'est plus ou moins celui utilisé par les processeurs x86, comme on le verra plus bas. Il existe des modèles de consistance mémoire plus relâchés, appelés des '''modèles de consistance faible''', qui autorisent diverses optimisations de spéculation mémoire, la combinaison d'écriture (fusionner deux écritures à la même adresse en une seule), etc. Ils sont supportés sur les architectures ARM et POWER. ===Les barrières mémoire=== Le changement d'ordre des lectures et écritures peut poser occasionnellement des problèmes avec la programmation pour les architectures multi-processeur ou multicœurs. Il existe des morceaux de code qui ne marchent que si la consistance séquentielle est respectée et il faut faire en sorte qu'ils marchent sur des processeurs avec un modèle de consistance mémoire différent. Pour cela, les processeurs incorporent des instructions appelées '''barrières mémoires''' ou '''''Fences'''''. Elles forcent le processeur à terminer tous les accès mémoire qui précédent dans l'ordre du programme. Certains processeurs possèdent une barrière mémoire pour les lectures et une autre pour les écritures. D'autres processeurs ajoutent des barrières mémoires dédiées à des cas particuliers. Elles permettent de forcer les accès à des données partagées à dérouler correctement. [[File:Fences.png|centre|vignette|upright=2|Fences.]] Leur implémentation est assez simple : elles empêchent l'émission de toute nouvelle instruction mémoire, tant qu'une condition précise n'est pas réunie. Par exemple, la barrière mémoire pour les écritures attend que la file d'écriture soit vidée avant de démarrer le moindre accès mémoire. Un compilateur peut placer les barrières mémoires au bon endroit, avec un peu d'aide du programmeur. Certains langages de programmation permettent d'indiquer au compilateur qu'une donnée doit toujours être lue depuis la mémoire RAM, via le mot-clé volatile. C'est très utile pour préciser que cette donnée est potentiellement partagée par plusieurs processeurs ou manipulable par des périphériques. Les compilateurs peuvent placer des barrières mémoires lors des lectures ou écritures sur ces variables, mais ce n'est pas une obligation. Il arrive aussi que le programmeur doive manipuler explicitement des barrières mémoires. Utiliser l'assembleur est alors une possibilité, mais qui est rarement exploitée, pour des raisons de portabilité. Pour limiter la casse, certains systèmes d'exploitations ou compilateurs peuvent aussi fournir des barrières mémoires explicites, encapsulées dans des bibliothèques ou cachées dans certaines fonctions. ===La modèle de consistance des processeurs x86=== Après avoir vu la théorie, passons maintenant à la pratique. Dans cette partie, on va voir les modèles de consistances utilisés sur les processeurs x86, ceux qu'on retrouve dans les PC actuels. Le modèle de consistance des processeurs x86 a varié au cours de l'existence de l'architecture : un vulgaire 486DX n'a pas le même modèle de consistance qu'un Core 2 duo, par exemple. Ils ont évolués au cours du temps, avec l'arrivée de nouvelles optimisations. De plus, leur formalisation a été progressive, et s'est faite au fur et à mesure. En général, les modèles de consistance des processeurs x86 ont toujours étés assez restrictifs, si on les compare aux autres processeurs. Il s'agit de modèles de type ''total store ordering'' (TSO), au moins dans les grandes lignes. Ils permettent donc d'effectuer certaines lectures en avance, soit parce que les lectures se font à des adresses différentes, soit par utilisation de réacheminement écriture vers lecture. Les possibilités d'exécution en avance des lectures varient suivant le processeur, elles deviennent de plus en plus performantes avec le temps. Le premier modèle de consistance est apparu sur les premiers processeurs x86 et est resté en place sur tous les processeurs de marque Pentium. Les processeurs de l'époque n'incorporaient pas beaucoup d'optimisations, le budget en transistor n'était pas assez conséquent pour ça, la mémoire n'était pas assez lente. Le modèle TSO de l'époque n'autorisaient que peu de lectures anticipées. Une lecture ne peut être exécutée en avance que sous les conditions suivantes : * les écritures contournées se font dans la mémoire cache ; * la lecture doit se faire dans la mémoire RAM ; * les écritures se font à une adresse différente de la lecture ; * aucune transaction avec un périphérique ne doit être en cours. A partir du Pentium 4, les choses changent. Le Pentium 4 est en effet le premier processeur à implémenter des techniques permettant d’exécuter plusieurs processus en parallèle, dont l'Hyperthreading. En conséquence, le modèle de consistance a dû être assoupli pour éviter de perdre bêtement en performance. Il s'agit d'un vrai modèle de type TSO, la majeure partie des contraintes sur les lectures anticipées sont réduites au minimum. Il y a aussi des contraintes supplémentaires pour les instructions dites atomiques, ainsi que pour les instructions qui accèdent aux périphériques ou aux entrées-sorties. Une optimisation séparée du TSO est que les écritures en mémoire peuvent changer d'ordre dans un cas exceptionnel : les écritures effectuées par les instructions de copie mémoire comme REPMOVSD, REPSCACB, ainsi que les instructions SSE MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, MOVNTPD. Les écritures effectuées dans ces instructions peuvent se faire dans un désordre complet (ou presque). Lors du passage au 64 bits, le modèle mémoire des processeurs x86 de l'époque a été formalisé dans un papier d'Intel appelé [https://www.cs.cmu.edu/~410-f10/doc/Intel_Reordering_318147.pdf Intel® 64 Architecture Memory Ordering White Paper], mis à jour en 2008 suite à quelques problèmes techniques. La formalisation du modèle a été d'une grande aide aux programmeurs, qui devaient se débrouiller avec des documentations qui ne formalisaient pas de modèle de consistance précis. Le document décrit un modèle de consistance mémoire portant le nom barbare de ''total lock order + causal consistency” (TLO+CC)'', qui admet plus d'optimisations que le modèle TSO. Mais la description était en réalité légèrement différente du modèle mémoire réellement implémenté sur les processeurs existants, qui utilisaient du TSO, sauf en de rares occasions. Les concepteurs de processeur font souvent cela, à savoir définir un modèle de consistance plus laxiste que réellement implémenté, pour des questions de comptabilité. Ainsi, si les futurs processeurs de la marque implémentent un modèle plus laxiste, les programmes déjà codés continuent de fonctionner. Le modèle décrit dans la documentation laisse de la marge pour le futur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le parallélisme mémoire | nextText=Le parallélisme mémoire }} </noinclude> 8i1g5s5htakzjjt04cyhzz6t5s9dqyu 774176 774175 2026-10-04T19:45:33Z Mewtow 31375 /* Les calculs d'adresse avec un scheduler mémoire */ 774176 wikitext text/x-wiki Dans les chapitres précédents, nous avons vu la mal-nommée exécution dans le désordre dans les chapitres précédents, qui devrait plutôt s'appeler l'émission dans le désordre. Elle modifie l'ordre des instructions pour gagner en efficacité, mais cela n'est valable que pour les instructions travaillant sur des registres ! Nous n'avons pas parlé de l'exécution dans le désordre des accès mémoire, car ils sont un peu à part. Et pour comprendre pourquoi, nous allons dédier ce chapitre à la gestion des accès mémoire dans le pipeline. Avant de poursuivre, faisons un rappel rapide sur les dépendances de données. Il faut distinguer deux types de dépendances de données : les dépendances de registres (deux instructions manipulent le même registre), et les dépendances d'adresse (deux instructions manipulent la même adresse). Deux instructions ont une dépendance d'adresse si elles écrivent ou lisent la même adresse mémoire. Des instructions mémoires qui lisent/écrivent des adresses différentes sont indépendantes. Lorsque deux instructions mémoire lisent/écrivent à la même adresse, on parle d''''aliasing mémoire'''. Formellement, l'aliasing mémoire n'est ni plus moins que l'application des dépendances de données aux instructions mémoire. Et comme les autres dépendances de données, il existe plusieurs types d'aliasing mémoire : RAR, RAW, WAR et WAW. Les dépendances RAR ne posent aucun problème, les lectures peuvent changer d'ordre sans aucun problème, tant qu'il n'y a pas d'écritures entre les deux. Par contre, les autres dépendances imposent trois contraintes. * Les dépendances RAW imposent que les lectures ne doivent pas s'exécuter avant une écriture à la même adresse. * Les dépendances WAR imposent que l'on ne peut pas déplacer une écriture avant une lecture à la même adresse. * Enfin, les dépendances WAW font que l'ordre des écritures doit être celui du programme : impossible de changer l'ordre de deux écritures. Les trois contraintes peuvent s'implémenter de plusieurs manières différentes. Comme pour les dépendances de données normales, seules les dépendances RAW sont de vraies dépendances de données. Les deux autres dépendances viennent du fait que l'on utilise la même adresse pour stocker des valeurs différentes. Détecter les cas d'''aliasing'' mémoire est primordial pour gérer la désambiguïsation mémoire, et cela demande de comparer des adresses. S'il existe des techniques similaires au renommage de registre permettent de les faire disparaitre, elles sont très limitées. Diverses optimisations permettent d'émettre des accès mémoire dans le désordre, exactement comme l’exécution dans le désordre le fait pour les autres instructions. Elles utilisent pour cela des ''schedulers'' spécialisées dans les accès mémoire. Il faut vraiment insister sur le fait que l'exécution dans le désordre proprement dite ne se préoccupe pas des micro-opérations mémoire. Pour faire la distinction, on parle de '''désambiguïsation mémoire''' (''memory disambiguation'') pour parler de l'exécution dans le désordre des accès mémoire. Le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. La raison est que la donnée lue est utilisée par d'autres instructions, dépendantes de la lecture. Elles sont donc à la tête d'une chaine de dépendances de données qui peut bloquer le pipeline si elle n'est pas résolue très tôt. Dès qu'une lecture peut être lancée, elle accède directement au cache de données. Par contre, si l'''aliasing'' ne le permet pas (écriture à la même adresse pas encore effectuée) ou que l'adresse à lire n'a pas encore été calculée, la lecture attend son tour. Le problème est que la désambiguïsation mémoire demande de comparer des adresses, pour détecter les dépendances d'adresse. Et cela pose de sérieuses contraintes pour son implémentation. L'exécution dans le désordre normale compare des registres, ce qui est beaucoup plus simple. Les registres utilisés sont directement encodés dans l'instruction elle-même, ce qui fait qu'on peut détecter les dépendances de registre après le décodage. Et elles peuvent même être éliminées par le renommage de registre pour les dépendances WAW et WAR. Par contre, les adresses ne sont pas encodées dans l'instruction elle-même (sauf avec le cas particulier de l'adressage absolu), mais sont lues depuis les registres ou calculées par une unité de calcul. Les adresses ne sont donc connues qu'une fois que l'instruction a attendu suffisamment de temps dans le ''scheduler''. ==L’émission dans l'ordre des accès mémoire== Avant toute chose, faisons quelques rappels sur l'exécution dans le désordre "normale", sans désambiguïsation mémoire. Les micro-opérations renommées sont mises en attente dans un ''scheduler'', le temps que leurs opérandes soient disponibles. Les µops mémoire font cependant exception. ===La file de µops mémoire=== Avec l''''émission dans l'ordre des accès mémoire''', le processeur émet les accès mémoire dans l'ordre du programme. Cela implique que l'unité mémoire n'a pas de ''scheduler'', ni de station de réservation associée. À la place, les micro-opérations mémoire sont placées dans une file de micro-opération dédiée, appelée la '''file de µops mémoire'''. La file de µops mémoire est une mémoire FIFO, dont chaque entrée peut accueillir une µops mémoire. [[File:Unité de gestion des accès mémoires dans l’ordre.png|centre|vignette|upright=2.0|Unité de gestion des accès mémoires dans l’ordre]] Par contre, le processeur utilise des ''schedulers'' pour les unités de calcul. Autant les accès mémoire se font dans l'ordre, autant les instructions arithmétiques/logiques/branchements/autres sont exécutées dans le désordre. En clair, le processeur supporte l'exécution dans le désordre, sauf pour les micro-opérations mémoire. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] Vu de l'extérieur, il faut attendre qu'un accès mémoire soit terminé pour lancer le suivant. Avant d'émettre une micro-opération mémoire, la file de micro-opération (son ''scoreboard'') vérifie si l'unité d'accès mémoire est libre ou non. Si elle est occupée, un accès mémoire est en cours, et on ne peut pas émettre de micro-opération mémoire. Une fois l'accès mémoire terminé, la mémoire ou le cache envoient un signal pour indiquer que l'accès est fini, et c'est au tour d'une nouvelle micro-opération mémoire. L'unité mémoire a un port sur lequel on envoie la micro-opération émise, que ce soit une écriture ou une lecture. Une file de µops implique la ''lecture après émission'', à savoir que les µops mémoire quittent la file de µops, avant de lire les registres adéquats. Il y a donc une connexion entre banc de registre et unité mémoire, comme sur n'importe quel autre processeur. Mais la file de µops n'est pas connectée au banc de registre, ni au réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] L'organisation précédente laisse penser que les calculs d'adresse sont forcément réalisés dans l'unité mémoire. Mais ce n'est pas une obligation. Il est parfaitement possible de scinder une µops mémoire en deux : une µop mémoire proprement dite et une µop de calcul d'adresse. La liaison entre les deux doit cependant se faire en utilisant un registre. L'adresse calculée est enregistrée dans un registre d'adresse "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. ===La file d'écriture : une FIFO de mise en attente des écritures=== L'unité mémoire exécute une µops mémoire à la fois. Avec un détail particulier : les écritures doivent attendre que toutes les instructions se terminent, avant de pouvoir s'exécuter. Sans ça, les exceptions précises sont impossibles, la prédiction de branchement l'est aussi et l'exécution dans le désordre aurait des conséquences fâcheuses (en exécutant des écritures en avance). Précisément, les écritures doivent respecter deux contraintes : s'exécuter dans le tout dernier étage du pipeline, s'exécuter dans l'ordre du programme. Il serait possible de bloquer l'émission des écritures tant que le ROB ne donne pas son accord. Mais ce n'est pas du tout ce qui est fait. A la place, les écritures sont mises en attente dans une mémoire FIFO, appelée la '''file d'écriture''', ou encore la ''Write Queue''. Les écritures sont mises en attente, jusqu'à ce que le ROB leur donne le okay pour transfer l'écriture en mémoire RAM. Nous en avions déjà parlé dans le chapitre sur le pipeline, dans la section sur les exceptions précises. Mais quelques rappels ne feront pas de mal. L'écriture est insérée dans la file d'écriture lorsqu'elle est émise, elle en sort à l'étage de ''Writeback'' si tout se passe bien. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente, faites à tort. De plus, la file d'écriture est une mémoire FIFO, qui mémorise les écritures dans l'ordre du programme. En effet, les écritures sont ajoutées dans la file d'écriture lorsqu'elles sont émises, et elles sont émises dans l'ordre du programme. [[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Write Queue sur un pipeline fixe]] La file d'écriture garantit que les écritures sont dans l'ordre du programme. Et cette propriété élimine totalement les dépendances WAW. Le fait que les écritures se font à la fin du pipeline élimine quant à elle les dépendances WAR. Il ne reste plus qu'à gérer les dépendances RAW, celles où une lecture lit une donnée écrite par une autre instruction. Une telle situation survient, car les écritures sont mises en attente pendant un certain temps, la donnée écrite peut être lue pendant ce laps de temps. La lecture ne peut pas lire la donnée dans le cache, car la donnée à lire n'y est pas encore enregistrée. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire, mais il existe. Il se manifeste surtout sur les processeurs avec peu de registre, où des données doivent régulièrement être déplacées temporairement en mémoire RAM. Pour éviter cela, une solution consiste mettre en attente les lectures si une écriture est en attente. Les lectures sont maintenues dans la file de µops tant que la file d'écriture n'est pas vide. La file de µops mémoire est suivie d'un circuit de détection des dépendances, un '''''scoreboard'' mémoire''' qui est assez simple. L'unité mémoire fournit un bit qui indique si la file d'écriture est vide ou non, ainsi qu'un second bit qui indique qu'elle est vide. Les deux ne sont pas compliqués à générer, ils font partie intégrante de l'interface de toute mémoire FIFO digne de ce nom. Le ''scoreboard'' mémoire utilise ces deux bits pour savoir s'il peut émettre une micro-opération mémoire. Il n'émet pas de lecture tant que la file d'écriture n'est pas vide. Il peut par contre émettre des écritures tant qu'elle n'est pas pleine. Une meilleure solution serait d'interdire les lectures, mais seulement en cas de dépendance mémoire détectée. Et c'est là qu'intervient la technique du '''''load bypassing'''''. Elle autorise une lecture, si elle n'a aucune dépendance avec les écritures mises en attente dans la file d'écriture. Elle doit être mise en attente dans le cas contraire. Pour l'implémenter, il faut comparer l'adresse à lire avec toutes les adresses dans la file d'écriture. La lecture est exécutée immédiatement si aucune correspondance n'est trouvée, mais elle est mise en attente dans le cas contraire. Pour cela, la file d'écriture est modifiée et devient un mélange entre FIFO et mémoire associative. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'' : la lecture doit être bloquée. Sinon, c'est un défaut de ''Write Queue'', la lecture accède au cache, là où se trouve la donnée à lire. Dans le chapitre sur le contournement, nous avons parlé d'une forme de contournement qui corrige ce problème. Les lectures peuvent lire la donnée depuis la file d'écriture si besoin, ce qui renvoie la donnée adéquate. Pour cela, les lectures consultent à la fois le cache et la file d'écriture, comme dit plus haut. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'', ou encore de '''réacheminement écriture-vers-lecture'''. [[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]] La lecture est mise en attente dans l'unité mémoire, vu que c'est elle qui fait les comparaisons d'adresse. Du point de vue extérieur, le ''scoreboard'' mémoire ne voit pas s’il y a eu succès dans la file d'écriture ou non. Il voit juste que l'unité mémoire est occupée et ne peut pas accepter de nouvelle lecture. Elle reste occupée durant quelques cycles, en cas de défaut de ''Write Queue'', le temps de faire la lecture dans le cache. Par contre, en cas de succès de ''Write Queue'', elle reste occupée tant que la lecture n'a pas donné de résultat, ce qui inclue le temps de mise en attente. Les processeurs haute performance implémentent tous le ''Store to load Forwarding'', mais quelques processeurs basse performance ne le font pas. Ils se contentent du ''load bypassing'', qui offre des performances correctes pour un cout en matériel bien plus modeste. Pour donner un exemple, la micro-architecture Jaguar d'AMD était dans ce cas. Elle a été utilisée sur les consoles de jeu de 8ème génération, comme la Xbox One et la Playstation 4. Les processeurs haute performance préférent utiliser plus de circuits pour implémenter le ''Store to load Forwarding'', mais l'implémentation n'est souvent pas complète. En effet, l'implémentation est souvent simple et ne prend en compte que des lectures/écritures à des adresses identiques, sans compter que la taille des données doit aussi être identique. Une écriture d'un entier de 32 bit sera contournée et envoyée à une lecture de 32 bits à la même adresse. Mais la lecture lit seulement 16 bits sur les 32 écrits, le contournement peut ne pas marcher. Concrètement, ce cas précis fonctionnera. Mais le cas inverse, avec une lecture lisant plusieurs écritures de taille plus petite, ne marchera pas sur tous les processeurs. Ne parlons pas du cas où les données écrites sont à cheval sur deux lignes de cache. ==L'exécution anticipée des calculs d'adresse== L'exécution dans le désordre fonctionne mieux si les lectures sont exécutées le plus tôt possible. Les lectures lisent des données qui sont utilisées par d'autres instructions. Si la lecture tarde, les autres instructions sont mises en attente. Exécuter les lectures le plus tôt possible est donc crucial. Mais comment faire ? Un moyen pour ça est de faire en sorte que les adresses à lire/écrire soient connues le plus tôt possible. Mais cela implique que les calculs d'adresse ne sont pas fait en même temps que l'accès mémoire. Pour cela, il est possible de séparer les µops mémoire en deux micro-instructions : une pour le calcul d'adresse et l'autre pour les accès mémoire. On parle de '''calcul anticipé des adresses'''. Avant de poursuivre, un minimum de terminologie. Nous parlerons d'AGU pour désigner les unités de calcul d'adresse, ce qui est une abréviation de ''Adresse Generation Unit''. Nous parlerons aussi de '''µops AGU''' pour parler des µops de calcul d'adresse. Le terme est de moi, il est assez parlant : "µops exécutés dans les AGUs". Nous utiliserons ce terme pour les séparer des µops entières classiques, comme les additions/soustractions/multiplications arithmétiques, les décalages/rotations, etc. Le calcul anticipé des adresses est utilisé avec ou sans désambiguïsation mémoire. Elle a des avantages avec et sans. Sans, elle calcule les adresses en avance, ce qui fait que des lectures/écritures mises en attente ont leurs opérandes prêtes plus rapidement. Avec, cela permet de connaitre les adresses en avance, ce qui permet de détecter plus rapidement les dépendances d'adresse. Mais laissons cela pour plus tard. ===La ''Load/Store Queue'' : une file de µops améliorée=== Implémenter cette technique demande de faire deux choses. Premièrement, il faut faire les calculs d'adresse en-dehors de l'unité mémoire, soit dans une ALU entière, soit dans une AGU dédiée. Deuxièmement, les adresses calculées doivent être mises en attente quelque part, et être liées facilement à la µop mémoire associée. Pour le second point, la file de µops mémoire parait être un bon candidat, mais n'est pas exactement la solution. Une file de µops au sens strict ne mémorise pas les opérandes des µops, donc pas d'adresse. Cela implique la lecture après émission, ce qui veut dire que l'adresse à lire/écrire est lue depuis les registres une fois que la µops sort de la file de µops (idem pour la donnée à écrire). En pratique, cette implémentation rend difficile de faire les calculs d'adresse ailleurs que dans l'unité mémoire. Heureusement, il est possible d'adapter la file de µops pour gérer la lecture avant émission. L'idée est qu'elle mémorise, pour chaque µop mémoire, l'adresse à lire/écrire et d'autres informations. Il ne s'agit donc plus vraiment d'une file de µops mémoire mais quelque chose d'autre, une sorte d'intermédiaire entre une station de réservation et une file de µops. D'un côté, elle fonctionne comme une file de µops car elle émet les µops dans l'ordre du programme. De l'autre, elle mémorise les opérandes des µops mémoire, ce qui en fait une pseudo-station de réservation. Une telle structure est appelée une '''''Load/Store Queue''''' (LSQ). Une LSQ contient plusieurs entrées, chaque entrée pouvant accueillir une µops mémoire et ses opérandes. Une entrée contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures. Les deux derniers champs sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. Elle contient aussi des bits de validité, qui indique si l'adresse ou la donnée sont disponibles, ou si le champ associé est vide. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2|Load Store Queue unifiée]] La LSQ doit faire le lien entre une adresse calculée par l'AGU, et l'entrée associée de la LSQ. Pour cela, l'unité d'émission associe un '''identifiant LSQ''' à une µops mémoire. L'identifiant est généré à l'émission. Aussi bien la µops de calcul d'adresse que a µop mémoire ont toutes deux le même identifiant. L'identifiant est propagé avec la µop de calcul d'adresse, comme tout signal de commande. En sortant de l'AGU, l’identifiant est transmis à la LSQ en même temps que l'adresse. La LSQ regarde quelle entrée a cet identifiant, et mémorise l'adresse dedans. La gestion des données à écrire se fait elle avec le système de contournement normal. La LSQ sait dans quel registre se trouve la donnée à écrire, elle surveille juste le système de contournement pour détecter quand la donnée est disponible. ===Le contournement des adresses mémoire avec une LSQ=== Avec une LSQ, les registres sont lus quand la µop mémoire est insérée dans la LSQ, si elles sont disponibles. Si elles ne sont pas encore disponibles, elles seront envoyées par le système de contournement plus tard. Cela implique qu'il y ait un système de contournement, qui relie les ALUs entières à la LSQ. Les ALUs envoient leur résultat à la LSQ, qui place alors l'adresse calculée dans la bonne entrée, la bonne "µop". [[File:Load-Store Queue.png|centre|vignette|upright=2|Load-Store Queue]] Une autre solution utilise des AGU spécialisées. L'avantage est que cela simplifie grandement le réseau de contournement : seules les AGU sont connectées à la LSQ. Un autre avantage est lié à l'adressage de la destination. En utilisant des ALU entières, il faut préciser que le résultat est à destination de la LSQ, pour configurer le réseau de contournement. Avec des AGU,, l'adresse calculée est envoyée automatiquement à la LSQ, pas besoin de préciser la destination. [[File:ALU et LSQ.png|centre|vignette|upright=2|AGUs et Load-Store Queue.]] Dans les deux cas, les µops AGU sont alors considérées comme des opérations entières un peu spéciales, gérées par le système d'exécution dans le désordre. Elles peuvent être émises dans le désordre, mais la LSQ se chargera de faire les accès mémoire dans l'ordre. Il y a donc calcul des adresses dans le désordre, mais accès mémoire dans l'ordre. ==Les ''schedulers'' mémoire unifiés== Avec la LSQ précédente, les µops mémoire s'exécutent dans l'ordre. Il arrive que des lectures prêtes soient mises en attente, parce qu'une lecture précédente attend ses opérandes. Et ce retard sur les lectures se répercute sur les instructions lecture-dépendantes, donc sur le reste du pipeline. Mais avec l'implémentation qui va suivre, il est possible exécuter les lectures dans le désordre tant que les conditions d'''aliasing'' sont respectées. L'implémentation améliore la LSQ pour en faire un ''scheduler'' spécialisé dans les µops mémoire. Il sera appelé le '''''scheduler'' mémoire''' dans ce qui suit, mais il est souvent appelé la LSQ dans certains documents. [[File:Processeur avec émission dans le désordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans le désordre des accès mémoire]] Lorsqu'une micro-opération mémoire est émise, elle est insérée dans le ''scheduler'' mémoire. Si l'adresse à lire/écrire est disponible, elle est immédiatement lue depuis les registres, idem pour une éventuelle donnée à écrire. Sinon, la micro-opération attend dans le ''scheduler'' mémoire que son adresse soit calculée par le processeur. ===Les calculs d'adresse avec un ''scheduler'' mémoire=== Le ''scheduler'' mémoire détecte des dépendances d'adresse entre instructions. Pour cela, il compare toutes les adresses à lire/écrire. Il mémorise donc les dites adresses, ce qui en fait une station de réservation. Et cela implique que ces adresses ont été calculées ailleurs, dans une ALU entière, ou dans une AGU séparée de l'unité mémoire. En somme, c'est le même problème qu'avec la LSQ vue plus haut. Là encore, l'implémentation la plus simple effectue les calculs d'adresse dans les ALU entières. La sortie de toutes les ALU entière est reliée au ''scheduler'' mémoire. [[File:Contournement pour la file de µops mémoire.png|centre|vignette|upright=2|''scheduler'' mémoire]] L'autre implémentation utilise des unités de calcul d'adresse dédiées, afin de simplifier le réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse via AGU.png|centre|vignette|upright=2|ALU et ''scheduler'' mémoire]] L'implémentation impose que toutes les instructions mémoire sont découpées en deux µops : une µop pour calculer l'adresse, et une µop mémoire. C'est un défaut assez important, car une instruction mémoire prendra deux places dans le ROB, dans les stations de réservation, etc. Vu que les instructions mémoire sont très fréquentes, la capacité du ROB/autres est alors fortement réduite. Mais diverses optimisations de macro-fusion permettent de réduire les problèmes. Mais nous laisson cela au chapitre sur les processeurs superscalaires. ===La détection des dépendances mémoire=== Le ''scheduler'' mémoire intègre un système de détection des dépendances, qui est bien différent de celui d'une station de réservation normale. Les ''schedulers'' mémoire comparent non pas des noms de registres, mais des adresses/opérandes. Pour détecter les dépendances, le ''scheduler'' mémoire compare les adresses à lire/écrire. Pour les µops dont l'adresse est inconnue, le cas dépend de l'implémentation. La version la plus conservatrice suppose qu'elle a des dépendances possibles avec toutes les autres. Chaque micro-opération en attente compare son adresse avec celle de toutes les micro-opérations précédentes. Ou presque, la comparaison ne doit tenir compte que des écritures, et de celles situées avant dans l'ordre du programme. Le ''scheduler'' mémoire doit donc mémoriser les instructions dans l'ordre du programme, tout en faisant des comparaisons d'adresse. Ça en fait une sorte hybride entre mémoire associative et mémoire FIFO. Nous avions vu dans le chapitre "L'exécution dans le désordre" qu'il y a plusieurs manières pour implémenter une station de réservation : avec une mémoire associative, avec préplanification, avec des matrices de dépendances. Le problème est qu'on ne peut pas utiliser une mémoire associative pour le ''scheduler'' mémoire, qui utilise des comparateurs pour comparer les noms de registres/adresses. En effet, la détection des dépendances mémoire demande de faire beaucoup de comparaisons : chaque adresse est comparé à toutes les autres. Et les adresses faisant facilement 32 à 64 bits, les comparateurs sont bien plus imposants que ceux qui comparent des noms de registres de 5-10 bits. Le cout en matériel serait trop élevé, de même que le temps de calcul des dépendances. À la place, on utilise des matrices de dépendances. La détection des dépendances mémoire utilise précisément deux matrices de ce type : une matrice de disponibilité, et une matrice de dépendance. Ces matrices forment une espèce de tableau carré, organisé en lignes et en colonnes. L'instruction dans la énième entrée se voit attribuer la énième ligne et la énième colonne. À l'intersection d'une ligne et d'une colonne, on trouve un bit qui indique si l'instruction de la ligne et celle de la colonne ont une dépendance. La '''matrice de disponibilité''' permet de bloquer l'exécution des lectures/écritures tant qu'une instruction précédente n'a pas calculé son adresse, tant qu'on ne peut pas vérifier les dépendances. Lorsqu'une instruction est ajoutée dans le ''scheduler'' mémoire, elle met tous les bits de la colonne attribuée à 1 si l'adresse à lire/écrire est inconnue, puis les met à 0 une fois l'adresse calculée. Une instruction ne peut pas démarrer si, sur la ligne qui lui est attribuée, se trouve un 1. Plus précisément, si on trouve un 1 pour les instructions plus anciennes, ce qui fait qu'une partie de la matrice est ignorée. Les bits notés X dans le tableau suivant sont ignorés, car ils précisent la disponibilité des opérandes d'un opérande ultérieure, ce qui est inutile. En clair, les calculs d'adresse modifient les colonnes, et on vérifie les lignes pour autoriser l'exécution. {|class="wikitable" |- ! Instructions mémoire dans la LSB, triées de la plus ancienne à la plus jeune ! 0 !! 1 !! 2 !! 3 !! 4 !! 5 !! 6 |- ! 0 | 1 || X || X || X || X || X || X |- ! 1 | 0 || 0 || X || X || X || X || X |- ! 2 | 0 || 1 || 0 || X || X || X || X |- ! 3 | 0 || 1 || 1 || 1 || X || X || X |- ! 4 | 0 || 0 || 0 || 0 || 1 || X || X |- ! 5 | 0 || 0 || 1 || 0 || 0 || 1 || X |- ! 6 | 1 || 1 || 1 || 1 || 1 || 1 || 0 |} Les '''matrices de dépendances''' fonctionnent sur le même principe, mais déterminent les dépendances mémoire. Lorsque le processeur a calculé une adresse, il la compare avec les adresses à lire/écrire déjà dans le ''scheduler'' mémoire. Il met alors à jour les bits de la colonne de la matrice de dépendance : le bit à l'intersection d'une ligne et d'une colonne est mis à 0 si les deux instructions n'ont pas de dépendances, à 1 si elles en ont une. Une instruction mémoire est exécutée si tous les bits de sa ligne sont à 0. Là encore, on ne tient compte que des bits pour les instructions plus anciennes, la matrice a encore une fois une forme triangulaire, comme pour la matrice de disponibilité. Une optimisation scinde le ''scheduler'' mémoire en deux structures, aux rôles légèrement différents. La première gère la disponibilité des opérandes, la seconde gère seulement les dépendances mémoire. La première est une station de réservation où les instructions sont mises en attente tant que leurs opérandes ne sont pas prêts. La seconde met en attente des micro-opérations dont les opérandes sont prêts, mais qui doivent attendre pour une autre raison : dépendance mémoire, cache occupé, etc. L'avantage est qu'on remplace une grosse station de réservation par deux plus petites, mais l'intérêt est surtout relié au système de détection des dépendances. Sans cette optimisation, on a deux matrices énormes reliées à une ''Load/Store Queue'' unique. Avec la séparation, la matrice de disponibilité est reliée uniquement à la première station de réservation, alors que la matrice de dépendance est reliée uniquement à l'autre. Les deux matrices sont donc plus petites, car reliées à deux stations de réservation mémoire plus petites. ==L'exécution spéculative des accès mémoires== Pour rappel, le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. Les données lues sont en effet, le départ d'une série d'instructions dépendantes, généralement des instructions arithmétiques, mais parfois d'autres lectures. Quand on manipule des structures de données, il n'est pas rare qu'une lecture lise un pointeur-adresse utilisé par une autre lecture. Les techniques de désambiguïsation mémoire que nous avons vu précédemment sont dites conservatrices, à savoir qu'elles ne changent pas l'ordre de deux instructions mémoire si elles ont une dépendance d'adresse. Elles exécutent une lecture le plus tôt possible, mais seulement si on sait que celle-ci n'aliase pas d'écritures précédentes. Et cela demande de connaitre les adresses des écritures précédentes dans l'ordre du programme. Si une lecture est précédée par une ou plusieurs écritures, les adresses à écrire doivent être connues. Il se peut en effet qu’une écriture dont on ne connait pas l'adresse aliase la lecture. La garantie est liée à la présence de la file de µops mémoire ou la file de µops STORE. Si une seule écriture a une adresse inconnue, elle reste dans la file de µops mémoire et bloque l'émission de toutes les autres micro-opérations mémoire, lecture comme écriture. Mais il existe des techniques dites spéculatives, qui modifient l'ordre des accès mémoire sans se soucier de leurs dépendances. Elles émettent les lectures dès que leurs opérandes sont prêts, sans se préoccuper de l'aliasing avec une écriture précédente. Pour éviter tout problème, le processeur vérifie si aucune dépendance mémoire n'est violée et corrige le tir dès qu'une violation est détectée. Le processeur vérifie s'il a fait une erreur de prédiction, en vérifiant les adresses à écrire ou lire. En cas d'erreur, le processeur fait comme lors d'une prédiction de branchement ratée : il remet le pipeline en ordre et ré-exécute les accès mémoire dans le bon ordre. Il s'agit d'un cas particulier d’'''exécution spéculative''', au même titre que la prédiction de branchement. Toutes les techniques de ce genre exécutent des lectures dans le désordre. L'avantage est que cela permet d'exécuter les lectures en avance. Vu que la donnée lue est l'opérande d'autres instructions, les exécuter en avance fait prendre de l'avance à un paquet d'instructions. Mais il faut comparer cet avantage au risque de mauvaise prédiction. Et aussi bizarre que cela puisse paraître, cela donne de bons résultats. Il faut dire que les situations où une lecture relit une donnée tout juste écrite sont rares : même pas 2 à 3 % des lectures. Les dépendances mémoire sont donc très rares, les cas d'''aliasing'' sont vraiment limités. En termes de cout en transistors, le hardware qui vérifie les dépendances mémoire reste globalement le même à peu de choses près, il est juste déplacé dans le pipeline et adapté à sa nouvelle fonction. ===La file de lectures=== La technique que nous allons voir émet les lectures dans l'ordre, ce qui simplifie énormément la gestion des dépendances. Les lectures sont émises dans l'ordre, les écritures aussi. Par contre, l'ordre entre lectures et écritures n'est pas respecté. Une lecture peut passer avant une écriture sans problèmes, voire avant plusieurs écritures d'un seul coup. Une telle lecture exécutée en avance d'une autre écriture est appelée une '''lecture spéculative'''. Une lecture spéculative peut donc sauter devant une ou plusieurs écritures, qui seront appelées des '''écritures sautées'''. Une écriture sautée est une précédant une lecture spéculative, dans l'ordre du programme. Évidemment, il se peut qu'une écriture sautée écrive la donnée qui est lue par la lecture spéculative. Il y a alors une violation de dépendance mémoire. Mais le processeur suppose qu'il n'y a pas de dépendantes de ce type et émet la lecture en avance. Rassurez-vous, il surveille quand même la situation, pour tout annuler ensuite s'il détecte une violation de dépendance mémoire. J'ai dit plus haut que le processeur émet les lectures dans l'ordre, ainsi que les écritures dans l'ordre. Pour cela, la LSQ est scindée en deux files de µops : une pour les µops LOAD et une autre pour les µops STORE. Les cours d'architecture des ordinateurs nomment souvent ces deux files en utilisant les termes de ''file de STORE'' (''store queue'') et de ''file de LOAD'' (''load queue''), mais il y a un risque de confusion avec la file d'écriture vue précédemment, et la file de lecture qu'on introduira dans la suite. Dans ce qui suit, nous parlerons de '''file de µops LOAD''' et de '''file de µops STORE''', pour éviter toute confusion. Les deux files sont chacune connectées à l'unité mémoire, via un port dédié. L'unité mémoire a donc un port pour les lectures, et un port pour les écritures. Le port d'écriture est relié à la file d'écriture, le port de lecture est directement relié au cache de données. Il faut noter qu'une unité mémoire à deux ports n'est pas si bizarre que cela. En effet, le cache de données a lui aussi deux ports, dans la majorité des cas, avec un port de lecture et un port d'écriture. [[File:Load Ordering, Store Ordering.jpg|centre|vignette|upright=2|Exécution dans le désordre des lectures.]] Maintenant, voyons comment sont détectées les violations de dépendances mémoire. Les dépendances qui peuvent être violées dans ce cas de figure se font entre une écriture sautée et une lecture spéculative. Si une lecture spéculative lit une donnée qui est écrite par une écriture sautée, alors une dépendance RAW a été violée. Pour les détecter, le processeur garde une trace des lectures spéculatives; tant que les instructions précédentes ne sont pas terminées. Pour cela, il utilise une file d'attente, qui mémorise les lecture spéculatives. Nous l'appellerons la ''file de vérification des lectures'', ou encore la '''file de lectures'''. Elle est une sorte d'équivalent de la file d'écriture, mais pour les lectures. Les lectures sortent de la file µops LOAD dès que leurs adresses sont calculées, sans tenir compte des dépendances mémoires. Elles sont alors ajoutées à la file de lectures et s'exécutent immédiatement, de manière spéculative. Pour vérifier qu'il n'y a pas d'erreur de spéculation, le processeur conserve l'adresse de la lecture effectuée dans la file de lectures. Les lectures quittent la file de lecture quand toutes les instructions précédentes dans l'ordre du programme sont terminées, à savoir tant quand elles ont quitté le tampon de réordonnancement/ROB. Notons que la file de vérification des lectures contient les lectures dans l'ordre du programme. En effet, les lectures sont accumulées en sortant de la file de µops LOAD, où elles sont triées dans l'ordre du programme. Et cela permet de gérer les erreurs de prédiction parfaitement, en annulant les instructions suivant une erreur de prédiction. [[File:Spéculation avec une file de lecture.jpg|centre|vignette|upright=2.5|Spéculation avec une file de lecture.]] Reste à détecter les erreurs de prédiction, ce qui peut être fait avec deux méthodes. La première détecte les violations de dépendances mémoire à chaque écriture. L'idée est que chaque écriture vérifie si une lecture anticipée n'a pas lue la donnée écrite en avance. Pour cela, quand une écriture est émise, elle consulte la file de lecture pour voir s'il y a un conflit d'adresse. La vérification fait cependant gaffe à ne prendre en compte que les lectures situées après l'écriture dans l'ordre du programme. Précisément, elle envoie l'adresse à écrire à la file de lecture et cherche si une lecture ultérieure a eu lieu à la même adresse. Si c'est le cas, cela signifie qu'une lecture anticipée aurait du lire la donnée actuellement écrite, mais que la lecture a été exécutée trop en avance. La lecture n'a pas lu la bonne donnée, il y a eu une violation de dépendance mémoire. Pour faire cette vérification, la file de lecture est un mélange entre FIFO et cache/mémoire associative, sur le même modèle que la file d'écriture. La différence est que la consultation de la file de lecture ne sert pas à faire du contournement, mais à détecter les dépendances. Quand une dépendance est détectée, l'écriture se fait normalement. Par contre, la lecture marquée comme invalide dans la file de lecture. L'exécution du programme se poursuit, jusqu'à ce que la lecture arrive à l'étape de ''commit'', à savoir que le ROB lui dit que les instructions précédentes sont terminées sans encombre. Là, la lecture étant invalide, le processeur déclenche une procédure de réparation, qui vide partiellement le pipeline de toutes les instructions qui suivent la lecture. La lecture est de plus ré-executée, afin de lire la bonne donnée. Avec la seconde méthode, les violations de dépendance mémoire sont détectées au moment où la lecture quitte à la file de lectures et le ROB. L'idée est de comparer la donnée lue de manière spéculative avec la donnée lue de manière non-spéculative. Pour faire la comparaison, la lecture spéculative copie la donnée lue dans la file de lecture. Quand la lecture quitte la file de lecture et le ROB, la lecture est ré-exécutée et la donnée relue. Le processeur compare alors la donnée lue à ce moment avec la donnée mémorisée dans la file de lecture. Si la donnée est différente, alors il y a eu erreur de violation de dépendance mémoire. Évidemment, relire une donnée n'est pas gratuit. Il est possible de limiter son impact en performance en dédiant un port de lecture sur le cache rien que pour ça. Mais le cout en circuit est comparable à l'usage d'une FIFO associative de la technique précédente. Mais quoi qu'il en soit, le cache doit permettre des lectures en un seul cycle, sans quoi la méthode ne marche pas vraiment. ===La prédiction de dépendances mémoires=== Certains processeurs essayent de prédire si deux accès mémoires sont dépendants : ils incorporent une unité qui va fonctionner comme une unité de prédiction de branchement, à la différence qu'elle prédit les dépendances mémoires. Si cette unité prédit que deux accès mémoires sont indépendants, le processeur les exécute dans le désordre, et les exécute dans l'ordre du programme dans le cas contraire. Il faut prendre en compte les erreurs de prédiction, ce qui est fait comme pour les mauvaises prédictions de branchement : on vide le pipeline. Beaucoup de techniques de prédiction des dépendances mémoire ont été inventées et en faire une revue exhaustive serait long et fastidieux. On pourrait citer les ensembles colorés (''color sets''), et bien d'autres techniques. Mais nous n'allons parler que des techniques les plus élémentaires. Quoi qu’il en soit, la recherche sur le sujet est assez riche, comme toujours quand il s'agit d'architecture des ordinateurs. On peut améliorer cette technique en mémorisant les instructions qui ont causé une mauvaise prédiction de dépendance mémoire. La prochaine fois qu'on exécute ces instructions, on sait qu'il y a de grandes chances qu'il se produise une erreur de prédiction, ce qui pousse à ne pas les exécuter de manière spéculative. On peut pour cela réutiliser les techniques de prédiction de branchement, tels des compteurs à saturations, mis à jour en cas d'erreurs de prédiction de dépendances mémoire. Dans tous les cas, on trouve un cache, équivalent au ''branch target buffer'', qui mémorise les instructions fautives (leur ''Program Counter''), avec d'autres informations comme les adresses des instructions dépendantes. La première classe de techniques du genre consiste à mémoriser les lectures qui ont causé une violation de dépendance, tandis que l'autre ne mémorise que les écritures. Cette dernière est l'approche utilisée par le '''cache de barrières d’écriture''' (store barrier cache). Enfin, nous allons conclure avec une dernière technique : celle des '''ensembles d’écritures''' (store sets). Cette technique est capable de gérer le cas où une lecture dépend de plusieurs écritures : le processeur mémorise l'ensemble des écritures avec lesquelles la lecture a déjà eu une dépendance. Cet ensemble d'écritures associées à une lecture est appelé tout simplement un ensemble d’écritures, et reçoit un identifiant (un nombre) par le processeur. Cette technique demande d'utiliser deux tables : * une qui assigne un identifiant d’ensemble d’écritures à l'instruction en cours ; * une autre qui mémorise les ensembles d’écritures sous la forme d'une liste chainée : le début de la liste correspond à l'écriture la plus ancienne de l’ensemble. Cela permet d'obtenir l'écriture la plus ancienne dans cet ensemble d’écritures directement. Quand le processeur détecte une violation de dépendance entre une lecture et une écriture, elle ajoute l'écriture dans l’ensemble d’écritures adéquat. Le déroulement d'une lecture demande d'accéder à la première table pour récupérer l'identifiant de l’ensemble d’écritures, et l'utiliser pour adresser la seconde table. S'il y a dépendance, cet accès renvoie l'écriture fautive en cas de dépendance. Quand une écriture est envoyée à l'unité mémoire, celle-ci va accéder à la table de correspondances de la même manière qu'une lecture, et va récupérer l'identifiant de l’ensemble d’écritures auquel elle appartient, identifiant qui est utilisé pour vérifier s'il y a une écriture en cours d’exécution. Si c'est le cas, cela signifie que l'écriture en cours d’exécution doit s’exécuter avant l'écriture qui a consulté la table : cette dernière est mise en attente. ==La prédiction d'adresse et de valeur== D'autres techniques de prédiction mémoire plus élaborées que les précédentes existent. Il s'agit de la prédiction d'adresse et de la prédiction de valeur. Leur nom trahit ce qu'elles font. La première technique tente de prédire l'adresse d'une lecture/écriture, alors que la seconde tente carrément de prédire quelle sera la donnée lue. Il y a bien de la recherche académique sur le sujet, mais il s'agit de techniques assez osées, dont on voit mal comment elles pourraient fonctionner. L'efficacité de cette technique a été étudiée grâce à des simulateurs. Suivant les études ou les programmes, on trouve des résultats qui varient pas mal. Dans certains cas, la performance baisse un peu, et dans d'autres, on peut avoir des gains de plus de 10% ! Mais dans des cas normaux, on trouve des gains de 4-5% environ. Ce qui est tout de même pas mal à l'heure actuelle. Cependant, en Janvier 2025, à l'heure où j'écris ces lignes, il a été découvert la première utilisation de ces deux techniques dans un processeur commercial. La découverte fait suite à la publication de deux vulnérabilités matérielles sur les CPU Apple M2 et M4 et quelques CPU ARM, qui font justement usage de ces deux techniques. Les deux vulnérabilités, appelées SLAP et FLOP, sont assez similaires aux attaques Spectre et Meltdown, sauf qu'elles attaquent non pas la prédiction de branchement, mais la prédiction d'adresse et de valeur du CPU. ===La prédiction d'adresse=== La prédiction d'adresse tente de prédire l'adresse d'une lecture à l'avance, ce qui permet de lancer les lectures en avance. Si la prédiction est correcte, on gagne quelques cycles qui permet aux unités de calcul de faire leurs calculs plus en avance, sans compter que les mécanismes de désambiguïsation mémoire fonctionnent plus efficacement. Si jamais cette adresse est connue plus tôt, on détecte les dépendances plus rapidement et on peut agir en conséquence. Des variantes de ces unités de prédiction d'adresse sont utilisées pour précharger des données depuis le cache de donnée. Et leur efficacité est assez bonne, voire excellente dans certains cas. La différence est que la prédiction d'adresse est utilisée pour prédire les adresses à lire depuis le cache vers les registres, ce qui est le sujet de cette sous-partie. La prédiction d'adresse est réalisée par une sorte de cache, qui associe à chaque instruction la prochaine adresse à lire. Nous appellerons ce cache la '''table de prédiction d'adresse'''. Pour faire l'association instruction <-> adresse lue/écrite, le tag du cache utilise l'adresse de l'instruction (le ''program Counter''), alors que la ligne de cache associée contient l'adresse prédite. Son contenu est utilisé pour faire une prédiction. Lorsqu'une lecture est émise, la table est consultée pour récupérer en avance l'adresse à lire, avant que celle-ci soit calculée par l'ALU. La lecture est alors exécutée en avance, avant que la vraie adresse soit connue, en utilisant l'adresse prédite. Reste qu'il faut des circuits autour de cette table de prédiction d'adresse, pour déterminer si la prédiction est correcte, ainsi que pour remplir cette table avec une adresse prédite. Pour cela, il y a un circuit de prédiction d'adresse dédié. Il récupère les instructions qui quittent le ROB ou la file des lectures mémoire, afin de vérifier les lectures qui viennent de se terminer/''commit'' et qui quittent le pipeline. Il peut contenir une '''table d'apprentissage''', qui mémorise des informations nécessaires pour faire des prédictions d'adresse. Par exemple, un historique des dernières adresses lues pour les lectures les plus récentes, ou un historique des adresses des lectures. La table est aussi utilisée pour déterminer si les prédictions effectuées étaient correctes ou non. Pour cela, il est possible de réutiliser les techniques vues dans le chapitre sur la prédiction de branchement, notamment des compteurs à saturation. Pour la prédiction de branchement et les autres formes de prédiction mémoire, les table d'apprentissage et pour les instructions/adresses sont fusionnées en une seule. Mais ici, il est nécessaire de les séparer pour une raison assez particulière : éliminer la pollution du cache dans la table de prédiction d'adresse. On ne peut pas allouer une ligne de cache pour chaque nouvelle instruction de lecture exécutée. Seule une minorité des lectures profitent de la prédiction d'adresse. Il est nécessaire de filtrer les lectures pour ne conserver que celles qui profitent de la prédiction d'adresse, ce qui est possible plus facilement en séparant la table d'apprentissage et les compteurs à saturation de la table de prédiction d'adresse. Une première méthode consiste à prédire que la lecture lit toujours la même adresse, ce qui lui vaut le nom de '''prédiction d'adresse constante'''. On pourrait se demander pourquoi une telle situation surviendrait. La raison est qu'il arrive que les compilateurs n'arrivent pas à gérer les accès mémoires efficacement. Dans certaines situations un peu compliquées impliquant des pointeurs ou des références, divers phénomènes complexes d'''aliasing'' des pointeurs peuvent générer des relectures intempestives de données en mémoire. Cela peut aussi arriver lorsqu'on compile du code qui provient de plusieurs librairies, ou quand du code lit régulièrement des constantes en mémoire ROM. La prédiction d'adresse constante ajoute un circuit qui analyse les dernières lectures, une fois qu'elles quittent le ROB, qu'elles ''commit''. Si, pour une instruction de lecture, l'adresse lue est toujours la même, alors l'instruction est ajoutée dans la table de prédiction d'adresse. Vu que les instructions qui accèdent toujours à la même adresse sont rares, il est préférable d'initialiser les compteurs à saturation de façon à ce qu'ils disent que toute nouvelle instruction change d'adresse. Il s'agit d'une méthode simple mais peu efficace, car cette situation est rare. Une autre méthode détecte les accès en enjambées, là encore quand les lectures quittent le ROB. Nous l’appellerons la '''prédiction d'adresse en enjambées'''. De tels accès surviennent régulièrement quand on accède des tableaux ou d'autres structures de données semblables, ce qui rend la technique très intéressante. Implémenter cette technique est facile : il suffit d'ajouter un circuit qui détecte les accès en enjambées, au lieu de celui qui détecte les accès constants. À chaque fois qu'une lecture entraine un succès de cache dans la table de prédiction d'adresse, l'enjambée est ajoutée à l'adresse contenue dans la table de prédiction d'adresse, l'ancienne adresse prédite est remplacée par celle avec l'enjambée d'ajoutée. Sur les Apple M2-M4, la prédiction d'adresse gére aussi bien les adresses constantes que les accès en enjambée. Le brevet qui décrit l'implémentation de l'unité de prédiction d'adresse et de valeur est potentiellement le suivant : [https://patents.google.com/patent/US11829763B2/en Early load execution via constant address and stride prediction]. En théorie, il est possible de faire mieux en repérant des accès mémoires qui se répètent de façon régulière et cyclique, même s'ils n'ont aucune enjambée. Ce genre d'accès se trouve assez souvent lorsque l'on manipule des listes chainées ou des structures de données assez irrégulières comme des arbres, des graphes, etc. Mais il n'y a pas encore de CPU qui implémente de telle optimisation. ===La prédiction de valeur : prédire la donnée lue=== Les techniques de '''prédiction de valeur''' (''Value Prediction'') consistent à prédire quelle est la donnée qui sera lue par une instruction de lecture. Oui, vous avez bien lu : le processeur est capable de parier sur la valeur qui sera chargée depuis la mémoire, de prédire si cette valeur vaut 0, 1, 1024, etc. Une fois son pari fait, il exécute les instructions du programme avec la valeur qu'il a pariée de façon spéculative. Si le pari est correct, le CPU continue l’exécution. Sinon, il est obligé de vider le pipeline et de recommencer avec la bonne valeur. Au premier abord, cette technique semble franchement mal partie tellement les chances de se tromper sont tellement énormes ! Mais le fait est que, dans certains cas, il est possible de spéculer correctement. Là encore, les techniques les plus simples fonctionnent dans deux cas. Le premier est celui où une donnée est relue à l'identique à chaque fois que la lecture est exécutée. Le second est celui où la donnée est incrémentée/décrémentée d'une constante qui est toujours la même d'une exécution de la lecture à l'autre. Une sorte d'enjambée constante, mais pour les données. Nous allons nous concentrer sur le premier cas, celui où une donnée est relue plusieurs fois sans être modifiée entre temps. L'idée de base est qu'une instruction de lecture qui s'exécute plusieurs fois de suite à la même adresse renvoie la même valeur à chaque fois. C'est parfaitement possible s’il n'y a eu aucune écriture à cette adresse entre-temps, d'où le fait qu'il faille surveiller si les prédictions constantes sont correctes. On peut se demander quelles sont les raisons qui font qu'une instruction de lecture renvoie la même valeur à chaque fois. Après tout, autant lire une seule fois la donnée et la garder dans un registre une bonne fois pour toutes ! Mais dans la réalité, on fait face à quelques limites, qui seront détaillées dans la liste suivante. La première est que le manque de registre fait que certaines données sont conservées en RAM et sont relues fréquemment. Cela arrive quand on stocke des constantes en mémoire. Par exemple, sur les processeurs x86, les constantes flottantes ne peuvent pas être intégrées dans nos instructions via le mode d'adressage immédiat. À la place, on les stocke en mémoire et on les charge dans les registres à chaque fois qu'on en a besoin. L'implémentation est similaire à la prédiction d'adresse, si ce n'est que c'est la donnée lue qui est prédite. Là encore, on a une '''table de prédiction de donnée lue''', consultée lorsqu'une lecture est émise. La table de prédiction de la donnée lue est une mémoire cache qui associe le ''Program Counter'' de la lecture à la donnée prédite, éventuellement couplé à des compteurs à saturation. Elle est consultée à chaque fois qu'une lecture est émise, la donnée adéquate est récupérée lors d'un succès de cache. On trouve aussi un circuit qui insère ou retire des instructions de la table de prédiction de donnée lue. Elle détecte les lectures prédictibles, couplé à une table d’apprentissage, et quelques circuits annexes liés au ROB ou aux files de lectures. Elle contient au minimum des compteurs à saturations, associés chacun à une instruction (son ''program counter'' pour être précis). ==L'optimisation du ''Mirroring memory operand''== Les processeurs modernes implémentent une optimisation concernant les accès mémoire. Cette optimisation a été décrite par Agner Fog dans ses manuels d'optimisation, sous le terme de '''''Mirroring memory operand'''''. Il a documenté son apparition sur les processeurs Zen 2 d'AMD, puis son évolution sur les processeurs Zen 4 et 5. L'optimisation est bizarrement absente de la microarchitecture Zen 3. Elle se manifeste dans une condition très précise : plusieurs instructions proches accèdent à la même adresse. Les deux instructions ne sont pas forcément consécutives, il peut y avoir une dizaine ou centaine d'instructions entre les deux. Une telle situation survient souvent sur les CPU CISC, et notamment sur les CPU x86, car ils ont peu de registres. les compilateurs font donc beaucoup d'échanges entre pile d'appel et registres, ce qui fait que des données écrites dans la pile sont souvent relue quelques instructions plus tard. Dans ce cas, la donnée lue/écrite est mémorisée dans un registre interne au processeur et peut être accédée très rapidement. Par exemple, imaginons plusieurs lectures proches à la même adresse, sans écriture entre elles. Dans ce cas, le processeur ne fera qu'une seule lecture en mémoire RAM, les lectures ultérieures liront la donnée depuis le registre interne. Comme autre exemple, imaginons une écriture suivie plus tard par une lecture à la même adresse. Dans ce cas, la donnée sera écrite dans un registre interne, en plus d'être propagée dans le cache ou la RAM, et la lecture ultérieure lira directement le registre interne. Le gain en performance est alors conséquent, comparé à du simple ''store-to-load forwarding''. Il faut noter que la technique marche aussi pour les opérations ''load-op'', pas seulement pour les instructions d'accès mémoire. Par exemple, prenons une écriture en RAM, suivies quelques instructions plus tard par une opération ''load-op'' à la même adresse. L'écriture écrit à une adresse, qui est lue par l'instruction ''load-op''. L'optimisation s'applique : l'instruction ''load-op'' lira l'opérande mémoire depuis un registre interne du processeur, qui contient une copie de la donnée écrite. Sur les processeurs Zen 2, l'optimisation marchait aussi pour les instructions PUSH et POP, qui lisaient/écrivaient dans la pile d'appel. Mais cette possibilité a été retirée sur les processeurs Zen 5. La technique détecte que deux instructions accèdent à la même adresse sous certaines conditions bien précises. L'optimisation ne marche que pour les modes d'adressages indicés et indirect, avec ou sans décalages, mais pas en mode absolu ou relatifs. Il faut préciser que l'optimisation ne marche que pour les registres généraux, pas les registres flottants. Les registres doivent être précisés de la même manière dans toutes les instructions. Par exemple, le processeur ne reconnait pas que l'addition des deux registres RCX + RAX est la même que RAX + RCX. Sur les CPU Zen 2 et 4, elle ne marchait que pour des opérandes de 32 et 64 bits, mais les processeurs Zen 5 gèrent des données de 8 et 16 bits aussi. Les décalages étaient limités à 8 bits sur le Zen 2, mais n'a plus de limites sur les Zen 4 et 5. ==La désambiguïsation mémoire a des effets de bord== La désambiguïsation mémoire a une caractéristique que l'exécution dans le désordre simple n'a pas : elle a des effets qui débordent sur la mémoire. Il est possible de savoir si un processeur fait de la désambiguïsation mémoire en regardant le bus mémoire. On s’aperçoit alors que les lectures se font dans un ordre différent de celui du programme. À l'opposé, l'exécution dans le désordre n'a pas d'effets observables de ce genre. Elle améliore la performance, elle modifie les durées observables des instructions, mais cela se limite à des questions de ''timings''. Le seul moyen de savoir si un processeur fait de l'exécution dans le désordre est de mesurer les latences/durées de chaque instruction. Mais aucun état observable au-delà des ''timings'' n'est modifié. Pas avec la désambiguïsation mémoire. ===La consistance mémoire=== Le fait que la désambiguïsation mémoire ait des effets observables ne pose aucun problème avec un seul processeur, ca elle est conçue pour. Par contre, les techniques de désambiguïsation mémoire peuvent poser des problèmes avec plusieurs processeurs. Il arrive que des programmes partagent une même portion de mémoire, et lisent/écrivent dedans. Avec plusieurs processeurs, chaque processeur effectue la désambiguïsation mémoire dans son coin sans se préoccuper des autres. Les opérations d'écriture peuvent être mises dans le désordre du point de vue des autres processeurs, et les lectures effectuées par les autres processeurs peuvent alors renvoyer de vielles données. Un exemple classique est celui de deux cœurs qui partagent le même cache ou la même mémoire, mais qui utilisent chacun une file d'écriture (''store buffer''). Imaginons que le premier cœur effectue une écriture, puis une lecture, à des adresses différentes. L'écriture est retardée grâce au tampon d'écriture, alors que la lecture lit directement le cache. Pour le premier processeur, l'écriture s'est réalisée avant la lecture, car pour lui, la fin de l'écriture signifie écrire dans le tampon d'écriture. Mais pour le second cœur, l'ordre s'est inversé : il voit la lecture dans le cache, puis l'écriture dans le cache. La raison est qu'il n'a pas connaissance du contenu du tampon d'écriture de l'autre cœur. Un autre exemple : les techniques de prédiction d'adresse et de valeur posent des problèmes avec les dépendances RAW. Elles permettent à une instruction mémoire de s'exécuter avant une autre, malgré la présence d'une dépendance RAW entre les deux. L'ordre entre les deux instructions change, alors que les autres processeurs ne devraient pas le voir. Et dans les faits, les processeurs qui incorporent ces techniques ont des modèles de consistance mémoire qui ne permettent pas ces optimisations en cas de dépendances RAW. Les deux techniques doivent idéalement être désactivées pour les instructions mémoire qui ont des dépendances RAW. Les programmeurs doivent tenir compte de ce genre de situations, dans des cas assez rares et liés à la programmation bas niveau. Pour cela, ils doivent savoir comment le processeur réorganise les accès mémoire. Concrètement, chaque processeur définit un '''modèle de consistance mémoire''' qui indique quelles optimisations sont activées et non. Il s'agit d'une sorte de contrat entre le logiciel et le matériel : les programmeurs savent comment le processeur va réorganiser les lectures et écritures et peuvent coder leurs programmes en fonction. L'implémentation du système de consistance mémoire dépend grandement de l'architecture, mais des modèles ont été standardisés afin de permettre aux programmeurs de savoir où placer les barrières mémoires. Il s'agit de vrais standards, formalisés, parfois mathématiquement, que des chercheurs étudient sur un plan mathématique et algorithmique. Le modèle préféré des programmeurs est la '''consistance séquentielle''', où les différents processeurs effectuent des accès mémoire dans l'ordre du programme et les accès mémoire simultanés sont interdits. La première contrainte interdit l'usage de mécanismes de désambiguïsation mémoire, la seconde interdit l'usage de caches non-bloquants. La consistance séquentielle a donc un cout en performance assez important, ce qui fait que les processeurs modernes n'utilisent pas la consistance séquentielle. Un modèle plus flexible est le '''''total store ordering''''', qui autorise la présence d'une file d'écriture et l'usage de caches non-bloquants pour les lectures (interdit d'avoir plusieurs écritures simultanées). C'est plus ou moins celui utilisé par les processeurs x86, comme on le verra plus bas. Il existe des modèles de consistance mémoire plus relâchés, appelés des '''modèles de consistance faible''', qui autorisent diverses optimisations de spéculation mémoire, la combinaison d'écriture (fusionner deux écritures à la même adresse en une seule), etc. Ils sont supportés sur les architectures ARM et POWER. ===Les barrières mémoire=== Le changement d'ordre des lectures et écritures peut poser occasionnellement des problèmes avec la programmation pour les architectures multi-processeur ou multicœurs. Il existe des morceaux de code qui ne marchent que si la consistance séquentielle est respectée et il faut faire en sorte qu'ils marchent sur des processeurs avec un modèle de consistance mémoire différent. Pour cela, les processeurs incorporent des instructions appelées '''barrières mémoires''' ou '''''Fences'''''. Elles forcent le processeur à terminer tous les accès mémoire qui précédent dans l'ordre du programme. Certains processeurs possèdent une barrière mémoire pour les lectures et une autre pour les écritures. D'autres processeurs ajoutent des barrières mémoires dédiées à des cas particuliers. Elles permettent de forcer les accès à des données partagées à dérouler correctement. [[File:Fences.png|centre|vignette|upright=2|Fences.]] Leur implémentation est assez simple : elles empêchent l'émission de toute nouvelle instruction mémoire, tant qu'une condition précise n'est pas réunie. Par exemple, la barrière mémoire pour les écritures attend que la file d'écriture soit vidée avant de démarrer le moindre accès mémoire. Un compilateur peut placer les barrières mémoires au bon endroit, avec un peu d'aide du programmeur. Certains langages de programmation permettent d'indiquer au compilateur qu'une donnée doit toujours être lue depuis la mémoire RAM, via le mot-clé volatile. C'est très utile pour préciser que cette donnée est potentiellement partagée par plusieurs processeurs ou manipulable par des périphériques. Les compilateurs peuvent placer des barrières mémoires lors des lectures ou écritures sur ces variables, mais ce n'est pas une obligation. Il arrive aussi que le programmeur doive manipuler explicitement des barrières mémoires. Utiliser l'assembleur est alors une possibilité, mais qui est rarement exploitée, pour des raisons de portabilité. Pour limiter la casse, certains systèmes d'exploitations ou compilateurs peuvent aussi fournir des barrières mémoires explicites, encapsulées dans des bibliothèques ou cachées dans certaines fonctions. ===La modèle de consistance des processeurs x86=== Après avoir vu la théorie, passons maintenant à la pratique. Dans cette partie, on va voir les modèles de consistances utilisés sur les processeurs x86, ceux qu'on retrouve dans les PC actuels. Le modèle de consistance des processeurs x86 a varié au cours de l'existence de l'architecture : un vulgaire 486DX n'a pas le même modèle de consistance qu'un Core 2 duo, par exemple. Ils ont évolués au cours du temps, avec l'arrivée de nouvelles optimisations. De plus, leur formalisation a été progressive, et s'est faite au fur et à mesure. En général, les modèles de consistance des processeurs x86 ont toujours étés assez restrictifs, si on les compare aux autres processeurs. Il s'agit de modèles de type ''total store ordering'' (TSO), au moins dans les grandes lignes. Ils permettent donc d'effectuer certaines lectures en avance, soit parce que les lectures se font à des adresses différentes, soit par utilisation de réacheminement écriture vers lecture. Les possibilités d'exécution en avance des lectures varient suivant le processeur, elles deviennent de plus en plus performantes avec le temps. Le premier modèle de consistance est apparu sur les premiers processeurs x86 et est resté en place sur tous les processeurs de marque Pentium. Les processeurs de l'époque n'incorporaient pas beaucoup d'optimisations, le budget en transistor n'était pas assez conséquent pour ça, la mémoire n'était pas assez lente. Le modèle TSO de l'époque n'autorisaient que peu de lectures anticipées. Une lecture ne peut être exécutée en avance que sous les conditions suivantes : * les écritures contournées se font dans la mémoire cache ; * la lecture doit se faire dans la mémoire RAM ; * les écritures se font à une adresse différente de la lecture ; * aucune transaction avec un périphérique ne doit être en cours. A partir du Pentium 4, les choses changent. Le Pentium 4 est en effet le premier processeur à implémenter des techniques permettant d’exécuter plusieurs processus en parallèle, dont l'Hyperthreading. En conséquence, le modèle de consistance a dû être assoupli pour éviter de perdre bêtement en performance. Il s'agit d'un vrai modèle de type TSO, la majeure partie des contraintes sur les lectures anticipées sont réduites au minimum. Il y a aussi des contraintes supplémentaires pour les instructions dites atomiques, ainsi que pour les instructions qui accèdent aux périphériques ou aux entrées-sorties. Une optimisation séparée du TSO est que les écritures en mémoire peuvent changer d'ordre dans un cas exceptionnel : les écritures effectuées par les instructions de copie mémoire comme REPMOVSD, REPSCACB, ainsi que les instructions SSE MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, MOVNTPD. Les écritures effectuées dans ces instructions peuvent se faire dans un désordre complet (ou presque). Lors du passage au 64 bits, le modèle mémoire des processeurs x86 de l'époque a été formalisé dans un papier d'Intel appelé [https://www.cs.cmu.edu/~410-f10/doc/Intel_Reordering_318147.pdf Intel® 64 Architecture Memory Ordering White Paper], mis à jour en 2008 suite à quelques problèmes techniques. La formalisation du modèle a été d'une grande aide aux programmeurs, qui devaient se débrouiller avec des documentations qui ne formalisaient pas de modèle de consistance précis. Le document décrit un modèle de consistance mémoire portant le nom barbare de ''total lock order + causal consistency” (TLO+CC)'', qui admet plus d'optimisations que le modèle TSO. Mais la description était en réalité légèrement différente du modèle mémoire réellement implémenté sur les processeurs existants, qui utilisaient du TSO, sauf en de rares occasions. Les concepteurs de processeur font souvent cela, à savoir définir un modèle de consistance plus laxiste que réellement implémenté, pour des questions de comptabilité. Ainsi, si les futurs processeurs de la marque implémentent un modèle plus laxiste, les programmes déjà codés continuent de fonctionner. Le modèle décrit dans la documentation laisse de la marge pour le futur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le parallélisme mémoire | nextText=Le parallélisme mémoire }} </noinclude> bnv3pzl56smornl0uv5o4qym60ral69 774177 774176 2026-10-04T19:45:58Z Mewtow 31375 /* Les calculs d'adresse avec un scheduler mémoire */ 774177 wikitext text/x-wiki Dans les chapitres précédents, nous avons vu la mal-nommée exécution dans le désordre dans les chapitres précédents, qui devrait plutôt s'appeler l'émission dans le désordre. Elle modifie l'ordre des instructions pour gagner en efficacité, mais cela n'est valable que pour les instructions travaillant sur des registres ! Nous n'avons pas parlé de l'exécution dans le désordre des accès mémoire, car ils sont un peu à part. Et pour comprendre pourquoi, nous allons dédier ce chapitre à la gestion des accès mémoire dans le pipeline. Avant de poursuivre, faisons un rappel rapide sur les dépendances de données. Il faut distinguer deux types de dépendances de données : les dépendances de registres (deux instructions manipulent le même registre), et les dépendances d'adresse (deux instructions manipulent la même adresse). Deux instructions ont une dépendance d'adresse si elles écrivent ou lisent la même adresse mémoire. Des instructions mémoires qui lisent/écrivent des adresses différentes sont indépendantes. Lorsque deux instructions mémoire lisent/écrivent à la même adresse, on parle d''''aliasing mémoire'''. Formellement, l'aliasing mémoire n'est ni plus moins que l'application des dépendances de données aux instructions mémoire. Et comme les autres dépendances de données, il existe plusieurs types d'aliasing mémoire : RAR, RAW, WAR et WAW. Les dépendances RAR ne posent aucun problème, les lectures peuvent changer d'ordre sans aucun problème, tant qu'il n'y a pas d'écritures entre les deux. Par contre, les autres dépendances imposent trois contraintes. * Les dépendances RAW imposent que les lectures ne doivent pas s'exécuter avant une écriture à la même adresse. * Les dépendances WAR imposent que l'on ne peut pas déplacer une écriture avant une lecture à la même adresse. * Enfin, les dépendances WAW font que l'ordre des écritures doit être celui du programme : impossible de changer l'ordre de deux écritures. Les trois contraintes peuvent s'implémenter de plusieurs manières différentes. Comme pour les dépendances de données normales, seules les dépendances RAW sont de vraies dépendances de données. Les deux autres dépendances viennent du fait que l'on utilise la même adresse pour stocker des valeurs différentes. Détecter les cas d'''aliasing'' mémoire est primordial pour gérer la désambiguïsation mémoire, et cela demande de comparer des adresses. S'il existe des techniques similaires au renommage de registre permettent de les faire disparaitre, elles sont très limitées. Diverses optimisations permettent d'émettre des accès mémoire dans le désordre, exactement comme l’exécution dans le désordre le fait pour les autres instructions. Elles utilisent pour cela des ''schedulers'' spécialisées dans les accès mémoire. Il faut vraiment insister sur le fait que l'exécution dans le désordre proprement dite ne se préoccupe pas des micro-opérations mémoire. Pour faire la distinction, on parle de '''désambiguïsation mémoire''' (''memory disambiguation'') pour parler de l'exécution dans le désordre des accès mémoire. Le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. La raison est que la donnée lue est utilisée par d'autres instructions, dépendantes de la lecture. Elles sont donc à la tête d'une chaine de dépendances de données qui peut bloquer le pipeline si elle n'est pas résolue très tôt. Dès qu'une lecture peut être lancée, elle accède directement au cache de données. Par contre, si l'''aliasing'' ne le permet pas (écriture à la même adresse pas encore effectuée) ou que l'adresse à lire n'a pas encore été calculée, la lecture attend son tour. Le problème est que la désambiguïsation mémoire demande de comparer des adresses, pour détecter les dépendances d'adresse. Et cela pose de sérieuses contraintes pour son implémentation. L'exécution dans le désordre normale compare des registres, ce qui est beaucoup plus simple. Les registres utilisés sont directement encodés dans l'instruction elle-même, ce qui fait qu'on peut détecter les dépendances de registre après le décodage. Et elles peuvent même être éliminées par le renommage de registre pour les dépendances WAW et WAR. Par contre, les adresses ne sont pas encodées dans l'instruction elle-même (sauf avec le cas particulier de l'adressage absolu), mais sont lues depuis les registres ou calculées par une unité de calcul. Les adresses ne sont donc connues qu'une fois que l'instruction a attendu suffisamment de temps dans le ''scheduler''. ==L’émission dans l'ordre des accès mémoire== Avant toute chose, faisons quelques rappels sur l'exécution dans le désordre "normale", sans désambiguïsation mémoire. Les micro-opérations renommées sont mises en attente dans un ''scheduler'', le temps que leurs opérandes soient disponibles. Les µops mémoire font cependant exception. ===La file de µops mémoire=== Avec l''''émission dans l'ordre des accès mémoire''', le processeur émet les accès mémoire dans l'ordre du programme. Cela implique que l'unité mémoire n'a pas de ''scheduler'', ni de station de réservation associée. À la place, les micro-opérations mémoire sont placées dans une file de micro-opération dédiée, appelée la '''file de µops mémoire'''. La file de µops mémoire est une mémoire FIFO, dont chaque entrée peut accueillir une µops mémoire. [[File:Unité de gestion des accès mémoires dans l’ordre.png|centre|vignette|upright=2.0|Unité de gestion des accès mémoires dans l’ordre]] Par contre, le processeur utilise des ''schedulers'' pour les unités de calcul. Autant les accès mémoire se font dans l'ordre, autant les instructions arithmétiques/logiques/branchements/autres sont exécutées dans le désordre. En clair, le processeur supporte l'exécution dans le désordre, sauf pour les micro-opérations mémoire. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] Vu de l'extérieur, il faut attendre qu'un accès mémoire soit terminé pour lancer le suivant. Avant d'émettre une micro-opération mémoire, la file de micro-opération (son ''scoreboard'') vérifie si l'unité d'accès mémoire est libre ou non. Si elle est occupée, un accès mémoire est en cours, et on ne peut pas émettre de micro-opération mémoire. Une fois l'accès mémoire terminé, la mémoire ou le cache envoient un signal pour indiquer que l'accès est fini, et c'est au tour d'une nouvelle micro-opération mémoire. L'unité mémoire a un port sur lequel on envoie la micro-opération émise, que ce soit une écriture ou une lecture. Une file de µops implique la ''lecture après émission'', à savoir que les µops mémoire quittent la file de µops, avant de lire les registres adéquats. Il y a donc une connexion entre banc de registre et unité mémoire, comme sur n'importe quel autre processeur. Mais la file de µops n'est pas connectée au banc de registre, ni au réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] L'organisation précédente laisse penser que les calculs d'adresse sont forcément réalisés dans l'unité mémoire. Mais ce n'est pas une obligation. Il est parfaitement possible de scinder une µops mémoire en deux : une µop mémoire proprement dite et une µop de calcul d'adresse. La liaison entre les deux doit cependant se faire en utilisant un registre. L'adresse calculée est enregistrée dans un registre d'adresse "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. ===La file d'écriture : une FIFO de mise en attente des écritures=== L'unité mémoire exécute une µops mémoire à la fois. Avec un détail particulier : les écritures doivent attendre que toutes les instructions se terminent, avant de pouvoir s'exécuter. Sans ça, les exceptions précises sont impossibles, la prédiction de branchement l'est aussi et l'exécution dans le désordre aurait des conséquences fâcheuses (en exécutant des écritures en avance). Précisément, les écritures doivent respecter deux contraintes : s'exécuter dans le tout dernier étage du pipeline, s'exécuter dans l'ordre du programme. Il serait possible de bloquer l'émission des écritures tant que le ROB ne donne pas son accord. Mais ce n'est pas du tout ce qui est fait. A la place, les écritures sont mises en attente dans une mémoire FIFO, appelée la '''file d'écriture''', ou encore la ''Write Queue''. Les écritures sont mises en attente, jusqu'à ce que le ROB leur donne le okay pour transfer l'écriture en mémoire RAM. Nous en avions déjà parlé dans le chapitre sur le pipeline, dans la section sur les exceptions précises. Mais quelques rappels ne feront pas de mal. L'écriture est insérée dans la file d'écriture lorsqu'elle est émise, elle en sort à l'étage de ''Writeback'' si tout se passe bien. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente, faites à tort. De plus, la file d'écriture est une mémoire FIFO, qui mémorise les écritures dans l'ordre du programme. En effet, les écritures sont ajoutées dans la file d'écriture lorsqu'elles sont émises, et elles sont émises dans l'ordre du programme. [[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Write Queue sur un pipeline fixe]] La file d'écriture garantit que les écritures sont dans l'ordre du programme. Et cette propriété élimine totalement les dépendances WAW. Le fait que les écritures se font à la fin du pipeline élimine quant à elle les dépendances WAR. Il ne reste plus qu'à gérer les dépendances RAW, celles où une lecture lit une donnée écrite par une autre instruction. Une telle situation survient, car les écritures sont mises en attente pendant un certain temps, la donnée écrite peut être lue pendant ce laps de temps. La lecture ne peut pas lire la donnée dans le cache, car la donnée à lire n'y est pas encore enregistrée. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire, mais il existe. Il se manifeste surtout sur les processeurs avec peu de registre, où des données doivent régulièrement être déplacées temporairement en mémoire RAM. Pour éviter cela, une solution consiste mettre en attente les lectures si une écriture est en attente. Les lectures sont maintenues dans la file de µops tant que la file d'écriture n'est pas vide. La file de µops mémoire est suivie d'un circuit de détection des dépendances, un '''''scoreboard'' mémoire''' qui est assez simple. L'unité mémoire fournit un bit qui indique si la file d'écriture est vide ou non, ainsi qu'un second bit qui indique qu'elle est vide. Les deux ne sont pas compliqués à générer, ils font partie intégrante de l'interface de toute mémoire FIFO digne de ce nom. Le ''scoreboard'' mémoire utilise ces deux bits pour savoir s'il peut émettre une micro-opération mémoire. Il n'émet pas de lecture tant que la file d'écriture n'est pas vide. Il peut par contre émettre des écritures tant qu'elle n'est pas pleine. Une meilleure solution serait d'interdire les lectures, mais seulement en cas de dépendance mémoire détectée. Et c'est là qu'intervient la technique du '''''load bypassing'''''. Elle autorise une lecture, si elle n'a aucune dépendance avec les écritures mises en attente dans la file d'écriture. Elle doit être mise en attente dans le cas contraire. Pour l'implémenter, il faut comparer l'adresse à lire avec toutes les adresses dans la file d'écriture. La lecture est exécutée immédiatement si aucune correspondance n'est trouvée, mais elle est mise en attente dans le cas contraire. Pour cela, la file d'écriture est modifiée et devient un mélange entre FIFO et mémoire associative. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'' : la lecture doit être bloquée. Sinon, c'est un défaut de ''Write Queue'', la lecture accède au cache, là où se trouve la donnée à lire. Dans le chapitre sur le contournement, nous avons parlé d'une forme de contournement qui corrige ce problème. Les lectures peuvent lire la donnée depuis la file d'écriture si besoin, ce qui renvoie la donnée adéquate. Pour cela, les lectures consultent à la fois le cache et la file d'écriture, comme dit plus haut. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'', ou encore de '''réacheminement écriture-vers-lecture'''. [[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]] La lecture est mise en attente dans l'unité mémoire, vu que c'est elle qui fait les comparaisons d'adresse. Du point de vue extérieur, le ''scoreboard'' mémoire ne voit pas s’il y a eu succès dans la file d'écriture ou non. Il voit juste que l'unité mémoire est occupée et ne peut pas accepter de nouvelle lecture. Elle reste occupée durant quelques cycles, en cas de défaut de ''Write Queue'', le temps de faire la lecture dans le cache. Par contre, en cas de succès de ''Write Queue'', elle reste occupée tant que la lecture n'a pas donné de résultat, ce qui inclue le temps de mise en attente. Les processeurs haute performance implémentent tous le ''Store to load Forwarding'', mais quelques processeurs basse performance ne le font pas. Ils se contentent du ''load bypassing'', qui offre des performances correctes pour un cout en matériel bien plus modeste. Pour donner un exemple, la micro-architecture Jaguar d'AMD était dans ce cas. Elle a été utilisée sur les consoles de jeu de 8ème génération, comme la Xbox One et la Playstation 4. Les processeurs haute performance préférent utiliser plus de circuits pour implémenter le ''Store to load Forwarding'', mais l'implémentation n'est souvent pas complète. En effet, l'implémentation est souvent simple et ne prend en compte que des lectures/écritures à des adresses identiques, sans compter que la taille des données doit aussi être identique. Une écriture d'un entier de 32 bit sera contournée et envoyée à une lecture de 32 bits à la même adresse. Mais la lecture lit seulement 16 bits sur les 32 écrits, le contournement peut ne pas marcher. Concrètement, ce cas précis fonctionnera. Mais le cas inverse, avec une lecture lisant plusieurs écritures de taille plus petite, ne marchera pas sur tous les processeurs. Ne parlons pas du cas où les données écrites sont à cheval sur deux lignes de cache. ==L'exécution anticipée des calculs d'adresse== L'exécution dans le désordre fonctionne mieux si les lectures sont exécutées le plus tôt possible. Les lectures lisent des données qui sont utilisées par d'autres instructions. Si la lecture tarde, les autres instructions sont mises en attente. Exécuter les lectures le plus tôt possible est donc crucial. Mais comment faire ? Un moyen pour ça est de faire en sorte que les adresses à lire/écrire soient connues le plus tôt possible. Mais cela implique que les calculs d'adresse ne sont pas fait en même temps que l'accès mémoire. Pour cela, il est possible de séparer les µops mémoire en deux micro-instructions : une pour le calcul d'adresse et l'autre pour les accès mémoire. On parle de '''calcul anticipé des adresses'''. Avant de poursuivre, un minimum de terminologie. Nous parlerons d'AGU pour désigner les unités de calcul d'adresse, ce qui est une abréviation de ''Adresse Generation Unit''. Nous parlerons aussi de '''µops AGU''' pour parler des µops de calcul d'adresse. Le terme est de moi, il est assez parlant : "µops exécutés dans les AGUs". Nous utiliserons ce terme pour les séparer des µops entières classiques, comme les additions/soustractions/multiplications arithmétiques, les décalages/rotations, etc. Le calcul anticipé des adresses est utilisé avec ou sans désambiguïsation mémoire. Elle a des avantages avec et sans. Sans, elle calcule les adresses en avance, ce qui fait que des lectures/écritures mises en attente ont leurs opérandes prêtes plus rapidement. Avec, cela permet de connaitre les adresses en avance, ce qui permet de détecter plus rapidement les dépendances d'adresse. Mais laissons cela pour plus tard. ===La ''Load/Store Queue'' : une file de µops améliorée=== Implémenter cette technique demande de faire deux choses. Premièrement, il faut faire les calculs d'adresse en-dehors de l'unité mémoire, soit dans une ALU entière, soit dans une AGU dédiée. Deuxièmement, les adresses calculées doivent être mises en attente quelque part, et être liées facilement à la µop mémoire associée. Pour le second point, la file de µops mémoire parait être un bon candidat, mais n'est pas exactement la solution. Une file de µops au sens strict ne mémorise pas les opérandes des µops, donc pas d'adresse. Cela implique la lecture après émission, ce qui veut dire que l'adresse à lire/écrire est lue depuis les registres une fois que la µops sort de la file de µops (idem pour la donnée à écrire). En pratique, cette implémentation rend difficile de faire les calculs d'adresse ailleurs que dans l'unité mémoire. Heureusement, il est possible d'adapter la file de µops pour gérer la lecture avant émission. L'idée est qu'elle mémorise, pour chaque µop mémoire, l'adresse à lire/écrire et d'autres informations. Il ne s'agit donc plus vraiment d'une file de µops mémoire mais quelque chose d'autre, une sorte d'intermédiaire entre une station de réservation et une file de µops. D'un côté, elle fonctionne comme une file de µops car elle émet les µops dans l'ordre du programme. De l'autre, elle mémorise les opérandes des µops mémoire, ce qui en fait une pseudo-station de réservation. Une telle structure est appelée une '''''Load/Store Queue''''' (LSQ). Une LSQ contient plusieurs entrées, chaque entrée pouvant accueillir une µops mémoire et ses opérandes. Une entrée contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures. Les deux derniers champs sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. Elle contient aussi des bits de validité, qui indique si l'adresse ou la donnée sont disponibles, ou si le champ associé est vide. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2|Load Store Queue unifiée]] La LSQ doit faire le lien entre une adresse calculée par l'AGU, et l'entrée associée de la LSQ. Pour cela, l'unité d'émission associe un '''identifiant LSQ''' à une µops mémoire. L'identifiant est généré à l'émission. Aussi bien la µops de calcul d'adresse que a µop mémoire ont toutes deux le même identifiant. L'identifiant est propagé avec la µop de calcul d'adresse, comme tout signal de commande. En sortant de l'AGU, l’identifiant est transmis à la LSQ en même temps que l'adresse. La LSQ regarde quelle entrée a cet identifiant, et mémorise l'adresse dedans. La gestion des données à écrire se fait elle avec le système de contournement normal. La LSQ sait dans quel registre se trouve la donnée à écrire, elle surveille juste le système de contournement pour détecter quand la donnée est disponible. ===Le contournement des adresses mémoire avec une LSQ=== Avec une LSQ, les registres sont lus quand la µop mémoire est insérée dans la LSQ, si elles sont disponibles. Si elles ne sont pas encore disponibles, elles seront envoyées par le système de contournement plus tard. Cela implique qu'il y ait un système de contournement, qui relie les ALUs entières à la LSQ. Les ALUs envoient leur résultat à la LSQ, qui place alors l'adresse calculée dans la bonne entrée, la bonne "µop". [[File:Load-Store Queue.png|centre|vignette|upright=2|Load-Store Queue]] Une autre solution utilise des AGU spécialisées. L'avantage est que cela simplifie grandement le réseau de contournement : seules les AGU sont connectées à la LSQ. Un autre avantage est lié à l'adressage de la destination. En utilisant des ALU entières, il faut préciser que le résultat est à destination de la LSQ, pour configurer le réseau de contournement. Avec des AGU,, l'adresse calculée est envoyée automatiquement à la LSQ, pas besoin de préciser la destination. [[File:ALU et LSQ.png|centre|vignette|upright=2|AGUs et Load-Store Queue.]] Dans les deux cas, les µops AGU sont alors considérées comme des opérations entières un peu spéciales, gérées par le système d'exécution dans le désordre. Elles peuvent être émises dans le désordre, mais la LSQ se chargera de faire les accès mémoire dans l'ordre. Il y a donc calcul des adresses dans le désordre, mais accès mémoire dans l'ordre. ==Les ''schedulers'' mémoire unifiés== Avec la LSQ précédente, les µops mémoire s'exécutent dans l'ordre. Il arrive que des lectures prêtes soient mises en attente, parce qu'une lecture précédente attend ses opérandes. Et ce retard sur les lectures se répercute sur les instructions lecture-dépendantes, donc sur le reste du pipeline. Mais avec l'implémentation qui va suivre, il est possible exécuter les lectures dans le désordre tant que les conditions d'''aliasing'' sont respectées. L'implémentation améliore la LSQ pour en faire un ''scheduler'' spécialisé dans les µops mémoire. Il sera appelé le '''''scheduler'' mémoire''' dans ce qui suit, mais il est souvent appelé la LSQ dans certains documents. [[File:Processeur avec émission dans le désordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans le désordre des accès mémoire]] Lorsqu'une micro-opération mémoire est émise, elle est insérée dans le ''scheduler'' mémoire. Si l'adresse à lire/écrire est disponible, elle est immédiatement lue depuis les registres, idem pour une éventuelle donnée à écrire. Sinon, la micro-opération attend dans le ''scheduler'' mémoire que son adresse soit calculée par le processeur. ===Les calculs d'adresse avec un ''scheduler'' mémoire=== Le ''scheduler'' mémoire détecte des dépendances d'adresse entre instructions. Pour cela, il compare toutes les adresses à lire/écrire. Il mémorise donc les dites adresses, ce qui en fait une station de réservation. Et cela implique que ces adresses ont été calculées ailleurs, dans une ALU entière, ou dans une AGU séparée de l'unité mémoire. En somme, c'est le même problème qu'avec la LSQ vue plus haut. Là encore, l'implémentation la plus simple effectue les calculs d'adresse dans les ALU entières. La sortie de toutes les ALU entière est reliée au ''scheduler'' mémoire. [[File:Contournement pour la file de µops mémoire.png|centre|vignette|upright=2|''scheduler'' mémoire]] L'autre implémentation utilise des unités de calcul d'adresse dédiées, afin de simplifier le réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse via AGU.png|centre|vignette|upright=2|ALU et ''scheduler'' mémoire]] ===La détection des dépendances mémoire=== Le ''scheduler'' mémoire intègre un système de détection des dépendances, qui est bien différent de celui d'une station de réservation normale. Les ''schedulers'' mémoire comparent non pas des noms de registres, mais des adresses/opérandes. Pour détecter les dépendances, le ''scheduler'' mémoire compare les adresses à lire/écrire. Pour les µops dont l'adresse est inconnue, le cas dépend de l'implémentation. La version la plus conservatrice suppose qu'elle a des dépendances possibles avec toutes les autres. Chaque micro-opération en attente compare son adresse avec celle de toutes les micro-opérations précédentes. Ou presque, la comparaison ne doit tenir compte que des écritures, et de celles situées avant dans l'ordre du programme. Le ''scheduler'' mémoire doit donc mémoriser les instructions dans l'ordre du programme, tout en faisant des comparaisons d'adresse. Ça en fait une sorte hybride entre mémoire associative et mémoire FIFO. Nous avions vu dans le chapitre "L'exécution dans le désordre" qu'il y a plusieurs manières pour implémenter une station de réservation : avec une mémoire associative, avec préplanification, avec des matrices de dépendances. Le problème est qu'on ne peut pas utiliser une mémoire associative pour le ''scheduler'' mémoire, qui utilise des comparateurs pour comparer les noms de registres/adresses. En effet, la détection des dépendances mémoire demande de faire beaucoup de comparaisons : chaque adresse est comparé à toutes les autres. Et les adresses faisant facilement 32 à 64 bits, les comparateurs sont bien plus imposants que ceux qui comparent des noms de registres de 5-10 bits. Le cout en matériel serait trop élevé, de même que le temps de calcul des dépendances. À la place, on utilise des matrices de dépendances. La détection des dépendances mémoire utilise précisément deux matrices de ce type : une matrice de disponibilité, et une matrice de dépendance. Ces matrices forment une espèce de tableau carré, organisé en lignes et en colonnes. L'instruction dans la énième entrée se voit attribuer la énième ligne et la énième colonne. À l'intersection d'une ligne et d'une colonne, on trouve un bit qui indique si l'instruction de la ligne et celle de la colonne ont une dépendance. La '''matrice de disponibilité''' permet de bloquer l'exécution des lectures/écritures tant qu'une instruction précédente n'a pas calculé son adresse, tant qu'on ne peut pas vérifier les dépendances. Lorsqu'une instruction est ajoutée dans le ''scheduler'' mémoire, elle met tous les bits de la colonne attribuée à 1 si l'adresse à lire/écrire est inconnue, puis les met à 0 une fois l'adresse calculée. Une instruction ne peut pas démarrer si, sur la ligne qui lui est attribuée, se trouve un 1. Plus précisément, si on trouve un 1 pour les instructions plus anciennes, ce qui fait qu'une partie de la matrice est ignorée. Les bits notés X dans le tableau suivant sont ignorés, car ils précisent la disponibilité des opérandes d'un opérande ultérieure, ce qui est inutile. En clair, les calculs d'adresse modifient les colonnes, et on vérifie les lignes pour autoriser l'exécution. {|class="wikitable" |- ! Instructions mémoire dans la LSB, triées de la plus ancienne à la plus jeune ! 0 !! 1 !! 2 !! 3 !! 4 !! 5 !! 6 |- ! 0 | 1 || X || X || X || X || X || X |- ! 1 | 0 || 0 || X || X || X || X || X |- ! 2 | 0 || 1 || 0 || X || X || X || X |- ! 3 | 0 || 1 || 1 || 1 || X || X || X |- ! 4 | 0 || 0 || 0 || 0 || 1 || X || X |- ! 5 | 0 || 0 || 1 || 0 || 0 || 1 || X |- ! 6 | 1 || 1 || 1 || 1 || 1 || 1 || 0 |} Les '''matrices de dépendances''' fonctionnent sur le même principe, mais déterminent les dépendances mémoire. Lorsque le processeur a calculé une adresse, il la compare avec les adresses à lire/écrire déjà dans le ''scheduler'' mémoire. Il met alors à jour les bits de la colonne de la matrice de dépendance : le bit à l'intersection d'une ligne et d'une colonne est mis à 0 si les deux instructions n'ont pas de dépendances, à 1 si elles en ont une. Une instruction mémoire est exécutée si tous les bits de sa ligne sont à 0. Là encore, on ne tient compte que des bits pour les instructions plus anciennes, la matrice a encore une fois une forme triangulaire, comme pour la matrice de disponibilité. Une optimisation scinde le ''scheduler'' mémoire en deux structures, aux rôles légèrement différents. La première gère la disponibilité des opérandes, la seconde gère seulement les dépendances mémoire. La première est une station de réservation où les instructions sont mises en attente tant que leurs opérandes ne sont pas prêts. La seconde met en attente des micro-opérations dont les opérandes sont prêts, mais qui doivent attendre pour une autre raison : dépendance mémoire, cache occupé, etc. L'avantage est qu'on remplace une grosse station de réservation par deux plus petites, mais l'intérêt est surtout relié au système de détection des dépendances. Sans cette optimisation, on a deux matrices énormes reliées à une ''Load/Store Queue'' unique. Avec la séparation, la matrice de disponibilité est reliée uniquement à la première station de réservation, alors que la matrice de dépendance est reliée uniquement à l'autre. Les deux matrices sont donc plus petites, car reliées à deux stations de réservation mémoire plus petites. ==L'exécution spéculative des accès mémoires== Pour rappel, le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. Les données lues sont en effet, le départ d'une série d'instructions dépendantes, généralement des instructions arithmétiques, mais parfois d'autres lectures. Quand on manipule des structures de données, il n'est pas rare qu'une lecture lise un pointeur-adresse utilisé par une autre lecture. Les techniques de désambiguïsation mémoire que nous avons vu précédemment sont dites conservatrices, à savoir qu'elles ne changent pas l'ordre de deux instructions mémoire si elles ont une dépendance d'adresse. Elles exécutent une lecture le plus tôt possible, mais seulement si on sait que celle-ci n'aliase pas d'écritures précédentes. Et cela demande de connaitre les adresses des écritures précédentes dans l'ordre du programme. Si une lecture est précédée par une ou plusieurs écritures, les adresses à écrire doivent être connues. Il se peut en effet qu’une écriture dont on ne connait pas l'adresse aliase la lecture. La garantie est liée à la présence de la file de µops mémoire ou la file de µops STORE. Si une seule écriture a une adresse inconnue, elle reste dans la file de µops mémoire et bloque l'émission de toutes les autres micro-opérations mémoire, lecture comme écriture. Mais il existe des techniques dites spéculatives, qui modifient l'ordre des accès mémoire sans se soucier de leurs dépendances. Elles émettent les lectures dès que leurs opérandes sont prêts, sans se préoccuper de l'aliasing avec une écriture précédente. Pour éviter tout problème, le processeur vérifie si aucune dépendance mémoire n'est violée et corrige le tir dès qu'une violation est détectée. Le processeur vérifie s'il a fait une erreur de prédiction, en vérifiant les adresses à écrire ou lire. En cas d'erreur, le processeur fait comme lors d'une prédiction de branchement ratée : il remet le pipeline en ordre et ré-exécute les accès mémoire dans le bon ordre. Il s'agit d'un cas particulier d’'''exécution spéculative''', au même titre que la prédiction de branchement. Toutes les techniques de ce genre exécutent des lectures dans le désordre. L'avantage est que cela permet d'exécuter les lectures en avance. Vu que la donnée lue est l'opérande d'autres instructions, les exécuter en avance fait prendre de l'avance à un paquet d'instructions. Mais il faut comparer cet avantage au risque de mauvaise prédiction. Et aussi bizarre que cela puisse paraître, cela donne de bons résultats. Il faut dire que les situations où une lecture relit une donnée tout juste écrite sont rares : même pas 2 à 3 % des lectures. Les dépendances mémoire sont donc très rares, les cas d'''aliasing'' sont vraiment limités. En termes de cout en transistors, le hardware qui vérifie les dépendances mémoire reste globalement le même à peu de choses près, il est juste déplacé dans le pipeline et adapté à sa nouvelle fonction. ===La file de lectures=== La technique que nous allons voir émet les lectures dans l'ordre, ce qui simplifie énormément la gestion des dépendances. Les lectures sont émises dans l'ordre, les écritures aussi. Par contre, l'ordre entre lectures et écritures n'est pas respecté. Une lecture peut passer avant une écriture sans problèmes, voire avant plusieurs écritures d'un seul coup. Une telle lecture exécutée en avance d'une autre écriture est appelée une '''lecture spéculative'''. Une lecture spéculative peut donc sauter devant une ou plusieurs écritures, qui seront appelées des '''écritures sautées'''. Une écriture sautée est une précédant une lecture spéculative, dans l'ordre du programme. Évidemment, il se peut qu'une écriture sautée écrive la donnée qui est lue par la lecture spéculative. Il y a alors une violation de dépendance mémoire. Mais le processeur suppose qu'il n'y a pas de dépendantes de ce type et émet la lecture en avance. Rassurez-vous, il surveille quand même la situation, pour tout annuler ensuite s'il détecte une violation de dépendance mémoire. J'ai dit plus haut que le processeur émet les lectures dans l'ordre, ainsi que les écritures dans l'ordre. Pour cela, la LSQ est scindée en deux files de µops : une pour les µops LOAD et une autre pour les µops STORE. Les cours d'architecture des ordinateurs nomment souvent ces deux files en utilisant les termes de ''file de STORE'' (''store queue'') et de ''file de LOAD'' (''load queue''), mais il y a un risque de confusion avec la file d'écriture vue précédemment, et la file de lecture qu'on introduira dans la suite. Dans ce qui suit, nous parlerons de '''file de µops LOAD''' et de '''file de µops STORE''', pour éviter toute confusion. Les deux files sont chacune connectées à l'unité mémoire, via un port dédié. L'unité mémoire a donc un port pour les lectures, et un port pour les écritures. Le port d'écriture est relié à la file d'écriture, le port de lecture est directement relié au cache de données. Il faut noter qu'une unité mémoire à deux ports n'est pas si bizarre que cela. En effet, le cache de données a lui aussi deux ports, dans la majorité des cas, avec un port de lecture et un port d'écriture. [[File:Load Ordering, Store Ordering.jpg|centre|vignette|upright=2|Exécution dans le désordre des lectures.]] Maintenant, voyons comment sont détectées les violations de dépendances mémoire. Les dépendances qui peuvent être violées dans ce cas de figure se font entre une écriture sautée et une lecture spéculative. Si une lecture spéculative lit une donnée qui est écrite par une écriture sautée, alors une dépendance RAW a été violée. Pour les détecter, le processeur garde une trace des lectures spéculatives; tant que les instructions précédentes ne sont pas terminées. Pour cela, il utilise une file d'attente, qui mémorise les lecture spéculatives. Nous l'appellerons la ''file de vérification des lectures'', ou encore la '''file de lectures'''. Elle est une sorte d'équivalent de la file d'écriture, mais pour les lectures. Les lectures sortent de la file µops LOAD dès que leurs adresses sont calculées, sans tenir compte des dépendances mémoires. Elles sont alors ajoutées à la file de lectures et s'exécutent immédiatement, de manière spéculative. Pour vérifier qu'il n'y a pas d'erreur de spéculation, le processeur conserve l'adresse de la lecture effectuée dans la file de lectures. Les lectures quittent la file de lecture quand toutes les instructions précédentes dans l'ordre du programme sont terminées, à savoir tant quand elles ont quitté le tampon de réordonnancement/ROB. Notons que la file de vérification des lectures contient les lectures dans l'ordre du programme. En effet, les lectures sont accumulées en sortant de la file de µops LOAD, où elles sont triées dans l'ordre du programme. Et cela permet de gérer les erreurs de prédiction parfaitement, en annulant les instructions suivant une erreur de prédiction. [[File:Spéculation avec une file de lecture.jpg|centre|vignette|upright=2.5|Spéculation avec une file de lecture.]] Reste à détecter les erreurs de prédiction, ce qui peut être fait avec deux méthodes. La première détecte les violations de dépendances mémoire à chaque écriture. L'idée est que chaque écriture vérifie si une lecture anticipée n'a pas lue la donnée écrite en avance. Pour cela, quand une écriture est émise, elle consulte la file de lecture pour voir s'il y a un conflit d'adresse. La vérification fait cependant gaffe à ne prendre en compte que les lectures situées après l'écriture dans l'ordre du programme. Précisément, elle envoie l'adresse à écrire à la file de lecture et cherche si une lecture ultérieure a eu lieu à la même adresse. Si c'est le cas, cela signifie qu'une lecture anticipée aurait du lire la donnée actuellement écrite, mais que la lecture a été exécutée trop en avance. La lecture n'a pas lu la bonne donnée, il y a eu une violation de dépendance mémoire. Pour faire cette vérification, la file de lecture est un mélange entre FIFO et cache/mémoire associative, sur le même modèle que la file d'écriture. La différence est que la consultation de la file de lecture ne sert pas à faire du contournement, mais à détecter les dépendances. Quand une dépendance est détectée, l'écriture se fait normalement. Par contre, la lecture marquée comme invalide dans la file de lecture. L'exécution du programme se poursuit, jusqu'à ce que la lecture arrive à l'étape de ''commit'', à savoir que le ROB lui dit que les instructions précédentes sont terminées sans encombre. Là, la lecture étant invalide, le processeur déclenche une procédure de réparation, qui vide partiellement le pipeline de toutes les instructions qui suivent la lecture. La lecture est de plus ré-executée, afin de lire la bonne donnée. Avec la seconde méthode, les violations de dépendance mémoire sont détectées au moment où la lecture quitte à la file de lectures et le ROB. L'idée est de comparer la donnée lue de manière spéculative avec la donnée lue de manière non-spéculative. Pour faire la comparaison, la lecture spéculative copie la donnée lue dans la file de lecture. Quand la lecture quitte la file de lecture et le ROB, la lecture est ré-exécutée et la donnée relue. Le processeur compare alors la donnée lue à ce moment avec la donnée mémorisée dans la file de lecture. Si la donnée est différente, alors il y a eu erreur de violation de dépendance mémoire. Évidemment, relire une donnée n'est pas gratuit. Il est possible de limiter son impact en performance en dédiant un port de lecture sur le cache rien que pour ça. Mais le cout en circuit est comparable à l'usage d'une FIFO associative de la technique précédente. Mais quoi qu'il en soit, le cache doit permettre des lectures en un seul cycle, sans quoi la méthode ne marche pas vraiment. ===La prédiction de dépendances mémoires=== Certains processeurs essayent de prédire si deux accès mémoires sont dépendants : ils incorporent une unité qui va fonctionner comme une unité de prédiction de branchement, à la différence qu'elle prédit les dépendances mémoires. Si cette unité prédit que deux accès mémoires sont indépendants, le processeur les exécute dans le désordre, et les exécute dans l'ordre du programme dans le cas contraire. Il faut prendre en compte les erreurs de prédiction, ce qui est fait comme pour les mauvaises prédictions de branchement : on vide le pipeline. Beaucoup de techniques de prédiction des dépendances mémoire ont été inventées et en faire une revue exhaustive serait long et fastidieux. On pourrait citer les ensembles colorés (''color sets''), et bien d'autres techniques. Mais nous n'allons parler que des techniques les plus élémentaires. Quoi qu’il en soit, la recherche sur le sujet est assez riche, comme toujours quand il s'agit d'architecture des ordinateurs. On peut améliorer cette technique en mémorisant les instructions qui ont causé une mauvaise prédiction de dépendance mémoire. La prochaine fois qu'on exécute ces instructions, on sait qu'il y a de grandes chances qu'il se produise une erreur de prédiction, ce qui pousse à ne pas les exécuter de manière spéculative. On peut pour cela réutiliser les techniques de prédiction de branchement, tels des compteurs à saturations, mis à jour en cas d'erreurs de prédiction de dépendances mémoire. Dans tous les cas, on trouve un cache, équivalent au ''branch target buffer'', qui mémorise les instructions fautives (leur ''Program Counter''), avec d'autres informations comme les adresses des instructions dépendantes. La première classe de techniques du genre consiste à mémoriser les lectures qui ont causé une violation de dépendance, tandis que l'autre ne mémorise que les écritures. Cette dernière est l'approche utilisée par le '''cache de barrières d’écriture''' (store barrier cache). Enfin, nous allons conclure avec une dernière technique : celle des '''ensembles d’écritures''' (store sets). Cette technique est capable de gérer le cas où une lecture dépend de plusieurs écritures : le processeur mémorise l'ensemble des écritures avec lesquelles la lecture a déjà eu une dépendance. Cet ensemble d'écritures associées à une lecture est appelé tout simplement un ensemble d’écritures, et reçoit un identifiant (un nombre) par le processeur. Cette technique demande d'utiliser deux tables : * une qui assigne un identifiant d’ensemble d’écritures à l'instruction en cours ; * une autre qui mémorise les ensembles d’écritures sous la forme d'une liste chainée : le début de la liste correspond à l'écriture la plus ancienne de l’ensemble. Cela permet d'obtenir l'écriture la plus ancienne dans cet ensemble d’écritures directement. Quand le processeur détecte une violation de dépendance entre une lecture et une écriture, elle ajoute l'écriture dans l’ensemble d’écritures adéquat. Le déroulement d'une lecture demande d'accéder à la première table pour récupérer l'identifiant de l’ensemble d’écritures, et l'utiliser pour adresser la seconde table. S'il y a dépendance, cet accès renvoie l'écriture fautive en cas de dépendance. Quand une écriture est envoyée à l'unité mémoire, celle-ci va accéder à la table de correspondances de la même manière qu'une lecture, et va récupérer l'identifiant de l’ensemble d’écritures auquel elle appartient, identifiant qui est utilisé pour vérifier s'il y a une écriture en cours d’exécution. Si c'est le cas, cela signifie que l'écriture en cours d’exécution doit s’exécuter avant l'écriture qui a consulté la table : cette dernière est mise en attente. ==La prédiction d'adresse et de valeur== D'autres techniques de prédiction mémoire plus élaborées que les précédentes existent. Il s'agit de la prédiction d'adresse et de la prédiction de valeur. Leur nom trahit ce qu'elles font. La première technique tente de prédire l'adresse d'une lecture/écriture, alors que la seconde tente carrément de prédire quelle sera la donnée lue. Il y a bien de la recherche académique sur le sujet, mais il s'agit de techniques assez osées, dont on voit mal comment elles pourraient fonctionner. L'efficacité de cette technique a été étudiée grâce à des simulateurs. Suivant les études ou les programmes, on trouve des résultats qui varient pas mal. Dans certains cas, la performance baisse un peu, et dans d'autres, on peut avoir des gains de plus de 10% ! Mais dans des cas normaux, on trouve des gains de 4-5% environ. Ce qui est tout de même pas mal à l'heure actuelle. Cependant, en Janvier 2025, à l'heure où j'écris ces lignes, il a été découvert la première utilisation de ces deux techniques dans un processeur commercial. La découverte fait suite à la publication de deux vulnérabilités matérielles sur les CPU Apple M2 et M4 et quelques CPU ARM, qui font justement usage de ces deux techniques. Les deux vulnérabilités, appelées SLAP et FLOP, sont assez similaires aux attaques Spectre et Meltdown, sauf qu'elles attaquent non pas la prédiction de branchement, mais la prédiction d'adresse et de valeur du CPU. ===La prédiction d'adresse=== La prédiction d'adresse tente de prédire l'adresse d'une lecture à l'avance, ce qui permet de lancer les lectures en avance. Si la prédiction est correcte, on gagne quelques cycles qui permet aux unités de calcul de faire leurs calculs plus en avance, sans compter que les mécanismes de désambiguïsation mémoire fonctionnent plus efficacement. Si jamais cette adresse est connue plus tôt, on détecte les dépendances plus rapidement et on peut agir en conséquence. Des variantes de ces unités de prédiction d'adresse sont utilisées pour précharger des données depuis le cache de donnée. Et leur efficacité est assez bonne, voire excellente dans certains cas. La différence est que la prédiction d'adresse est utilisée pour prédire les adresses à lire depuis le cache vers les registres, ce qui est le sujet de cette sous-partie. La prédiction d'adresse est réalisée par une sorte de cache, qui associe à chaque instruction la prochaine adresse à lire. Nous appellerons ce cache la '''table de prédiction d'adresse'''. Pour faire l'association instruction <-> adresse lue/écrite, le tag du cache utilise l'adresse de l'instruction (le ''program Counter''), alors que la ligne de cache associée contient l'adresse prédite. Son contenu est utilisé pour faire une prédiction. Lorsqu'une lecture est émise, la table est consultée pour récupérer en avance l'adresse à lire, avant que celle-ci soit calculée par l'ALU. La lecture est alors exécutée en avance, avant que la vraie adresse soit connue, en utilisant l'adresse prédite. Reste qu'il faut des circuits autour de cette table de prédiction d'adresse, pour déterminer si la prédiction est correcte, ainsi que pour remplir cette table avec une adresse prédite. Pour cela, il y a un circuit de prédiction d'adresse dédié. Il récupère les instructions qui quittent le ROB ou la file des lectures mémoire, afin de vérifier les lectures qui viennent de se terminer/''commit'' et qui quittent le pipeline. Il peut contenir une '''table d'apprentissage''', qui mémorise des informations nécessaires pour faire des prédictions d'adresse. Par exemple, un historique des dernières adresses lues pour les lectures les plus récentes, ou un historique des adresses des lectures. La table est aussi utilisée pour déterminer si les prédictions effectuées étaient correctes ou non. Pour cela, il est possible de réutiliser les techniques vues dans le chapitre sur la prédiction de branchement, notamment des compteurs à saturation. Pour la prédiction de branchement et les autres formes de prédiction mémoire, les table d'apprentissage et pour les instructions/adresses sont fusionnées en une seule. Mais ici, il est nécessaire de les séparer pour une raison assez particulière : éliminer la pollution du cache dans la table de prédiction d'adresse. On ne peut pas allouer une ligne de cache pour chaque nouvelle instruction de lecture exécutée. Seule une minorité des lectures profitent de la prédiction d'adresse. Il est nécessaire de filtrer les lectures pour ne conserver que celles qui profitent de la prédiction d'adresse, ce qui est possible plus facilement en séparant la table d'apprentissage et les compteurs à saturation de la table de prédiction d'adresse. Une première méthode consiste à prédire que la lecture lit toujours la même adresse, ce qui lui vaut le nom de '''prédiction d'adresse constante'''. On pourrait se demander pourquoi une telle situation surviendrait. La raison est qu'il arrive que les compilateurs n'arrivent pas à gérer les accès mémoires efficacement. Dans certaines situations un peu compliquées impliquant des pointeurs ou des références, divers phénomènes complexes d'''aliasing'' des pointeurs peuvent générer des relectures intempestives de données en mémoire. Cela peut aussi arriver lorsqu'on compile du code qui provient de plusieurs librairies, ou quand du code lit régulièrement des constantes en mémoire ROM. La prédiction d'adresse constante ajoute un circuit qui analyse les dernières lectures, une fois qu'elles quittent le ROB, qu'elles ''commit''. Si, pour une instruction de lecture, l'adresse lue est toujours la même, alors l'instruction est ajoutée dans la table de prédiction d'adresse. Vu que les instructions qui accèdent toujours à la même adresse sont rares, il est préférable d'initialiser les compteurs à saturation de façon à ce qu'ils disent que toute nouvelle instruction change d'adresse. Il s'agit d'une méthode simple mais peu efficace, car cette situation est rare. Une autre méthode détecte les accès en enjambées, là encore quand les lectures quittent le ROB. Nous l’appellerons la '''prédiction d'adresse en enjambées'''. De tels accès surviennent régulièrement quand on accède des tableaux ou d'autres structures de données semblables, ce qui rend la technique très intéressante. Implémenter cette technique est facile : il suffit d'ajouter un circuit qui détecte les accès en enjambées, au lieu de celui qui détecte les accès constants. À chaque fois qu'une lecture entraine un succès de cache dans la table de prédiction d'adresse, l'enjambée est ajoutée à l'adresse contenue dans la table de prédiction d'adresse, l'ancienne adresse prédite est remplacée par celle avec l'enjambée d'ajoutée. Sur les Apple M2-M4, la prédiction d'adresse gére aussi bien les adresses constantes que les accès en enjambée. Le brevet qui décrit l'implémentation de l'unité de prédiction d'adresse et de valeur est potentiellement le suivant : [https://patents.google.com/patent/US11829763B2/en Early load execution via constant address and stride prediction]. En théorie, il est possible de faire mieux en repérant des accès mémoires qui se répètent de façon régulière et cyclique, même s'ils n'ont aucune enjambée. Ce genre d'accès se trouve assez souvent lorsque l'on manipule des listes chainées ou des structures de données assez irrégulières comme des arbres, des graphes, etc. Mais il n'y a pas encore de CPU qui implémente de telle optimisation. ===La prédiction de valeur : prédire la donnée lue=== Les techniques de '''prédiction de valeur''' (''Value Prediction'') consistent à prédire quelle est la donnée qui sera lue par une instruction de lecture. Oui, vous avez bien lu : le processeur est capable de parier sur la valeur qui sera chargée depuis la mémoire, de prédire si cette valeur vaut 0, 1, 1024, etc. Une fois son pari fait, il exécute les instructions du programme avec la valeur qu'il a pariée de façon spéculative. Si le pari est correct, le CPU continue l’exécution. Sinon, il est obligé de vider le pipeline et de recommencer avec la bonne valeur. Au premier abord, cette technique semble franchement mal partie tellement les chances de se tromper sont tellement énormes ! Mais le fait est que, dans certains cas, il est possible de spéculer correctement. Là encore, les techniques les plus simples fonctionnent dans deux cas. Le premier est celui où une donnée est relue à l'identique à chaque fois que la lecture est exécutée. Le second est celui où la donnée est incrémentée/décrémentée d'une constante qui est toujours la même d'une exécution de la lecture à l'autre. Une sorte d'enjambée constante, mais pour les données. Nous allons nous concentrer sur le premier cas, celui où une donnée est relue plusieurs fois sans être modifiée entre temps. L'idée de base est qu'une instruction de lecture qui s'exécute plusieurs fois de suite à la même adresse renvoie la même valeur à chaque fois. C'est parfaitement possible s’il n'y a eu aucune écriture à cette adresse entre-temps, d'où le fait qu'il faille surveiller si les prédictions constantes sont correctes. On peut se demander quelles sont les raisons qui font qu'une instruction de lecture renvoie la même valeur à chaque fois. Après tout, autant lire une seule fois la donnée et la garder dans un registre une bonne fois pour toutes ! Mais dans la réalité, on fait face à quelques limites, qui seront détaillées dans la liste suivante. La première est que le manque de registre fait que certaines données sont conservées en RAM et sont relues fréquemment. Cela arrive quand on stocke des constantes en mémoire. Par exemple, sur les processeurs x86, les constantes flottantes ne peuvent pas être intégrées dans nos instructions via le mode d'adressage immédiat. À la place, on les stocke en mémoire et on les charge dans les registres à chaque fois qu'on en a besoin. L'implémentation est similaire à la prédiction d'adresse, si ce n'est que c'est la donnée lue qui est prédite. Là encore, on a une '''table de prédiction de donnée lue''', consultée lorsqu'une lecture est émise. La table de prédiction de la donnée lue est une mémoire cache qui associe le ''Program Counter'' de la lecture à la donnée prédite, éventuellement couplé à des compteurs à saturation. Elle est consultée à chaque fois qu'une lecture est émise, la donnée adéquate est récupérée lors d'un succès de cache. On trouve aussi un circuit qui insère ou retire des instructions de la table de prédiction de donnée lue. Elle détecte les lectures prédictibles, couplé à une table d’apprentissage, et quelques circuits annexes liés au ROB ou aux files de lectures. Elle contient au minimum des compteurs à saturations, associés chacun à une instruction (son ''program counter'' pour être précis). ==L'optimisation du ''Mirroring memory operand''== Les processeurs modernes implémentent une optimisation concernant les accès mémoire. Cette optimisation a été décrite par Agner Fog dans ses manuels d'optimisation, sous le terme de '''''Mirroring memory operand'''''. Il a documenté son apparition sur les processeurs Zen 2 d'AMD, puis son évolution sur les processeurs Zen 4 et 5. L'optimisation est bizarrement absente de la microarchitecture Zen 3. Elle se manifeste dans une condition très précise : plusieurs instructions proches accèdent à la même adresse. Les deux instructions ne sont pas forcément consécutives, il peut y avoir une dizaine ou centaine d'instructions entre les deux. Une telle situation survient souvent sur les CPU CISC, et notamment sur les CPU x86, car ils ont peu de registres. les compilateurs font donc beaucoup d'échanges entre pile d'appel et registres, ce qui fait que des données écrites dans la pile sont souvent relue quelques instructions plus tard. Dans ce cas, la donnée lue/écrite est mémorisée dans un registre interne au processeur et peut être accédée très rapidement. Par exemple, imaginons plusieurs lectures proches à la même adresse, sans écriture entre elles. Dans ce cas, le processeur ne fera qu'une seule lecture en mémoire RAM, les lectures ultérieures liront la donnée depuis le registre interne. Comme autre exemple, imaginons une écriture suivie plus tard par une lecture à la même adresse. Dans ce cas, la donnée sera écrite dans un registre interne, en plus d'être propagée dans le cache ou la RAM, et la lecture ultérieure lira directement le registre interne. Le gain en performance est alors conséquent, comparé à du simple ''store-to-load forwarding''. Il faut noter que la technique marche aussi pour les opérations ''load-op'', pas seulement pour les instructions d'accès mémoire. Par exemple, prenons une écriture en RAM, suivies quelques instructions plus tard par une opération ''load-op'' à la même adresse. L'écriture écrit à une adresse, qui est lue par l'instruction ''load-op''. L'optimisation s'applique : l'instruction ''load-op'' lira l'opérande mémoire depuis un registre interne du processeur, qui contient une copie de la donnée écrite. Sur les processeurs Zen 2, l'optimisation marchait aussi pour les instructions PUSH et POP, qui lisaient/écrivaient dans la pile d'appel. Mais cette possibilité a été retirée sur les processeurs Zen 5. La technique détecte que deux instructions accèdent à la même adresse sous certaines conditions bien précises. L'optimisation ne marche que pour les modes d'adressages indicés et indirect, avec ou sans décalages, mais pas en mode absolu ou relatifs. Il faut préciser que l'optimisation ne marche que pour les registres généraux, pas les registres flottants. Les registres doivent être précisés de la même manière dans toutes les instructions. Par exemple, le processeur ne reconnait pas que l'addition des deux registres RCX + RAX est la même que RAX + RCX. Sur les CPU Zen 2 et 4, elle ne marchait que pour des opérandes de 32 et 64 bits, mais les processeurs Zen 5 gèrent des données de 8 et 16 bits aussi. Les décalages étaient limités à 8 bits sur le Zen 2, mais n'a plus de limites sur les Zen 4 et 5. ==La désambiguïsation mémoire a des effets de bord== La désambiguïsation mémoire a une caractéristique que l'exécution dans le désordre simple n'a pas : elle a des effets qui débordent sur la mémoire. Il est possible de savoir si un processeur fait de la désambiguïsation mémoire en regardant le bus mémoire. On s’aperçoit alors que les lectures se font dans un ordre différent de celui du programme. À l'opposé, l'exécution dans le désordre n'a pas d'effets observables de ce genre. Elle améliore la performance, elle modifie les durées observables des instructions, mais cela se limite à des questions de ''timings''. Le seul moyen de savoir si un processeur fait de l'exécution dans le désordre est de mesurer les latences/durées de chaque instruction. Mais aucun état observable au-delà des ''timings'' n'est modifié. Pas avec la désambiguïsation mémoire. ===La consistance mémoire=== Le fait que la désambiguïsation mémoire ait des effets observables ne pose aucun problème avec un seul processeur, ca elle est conçue pour. Par contre, les techniques de désambiguïsation mémoire peuvent poser des problèmes avec plusieurs processeurs. Il arrive que des programmes partagent une même portion de mémoire, et lisent/écrivent dedans. Avec plusieurs processeurs, chaque processeur effectue la désambiguïsation mémoire dans son coin sans se préoccuper des autres. Les opérations d'écriture peuvent être mises dans le désordre du point de vue des autres processeurs, et les lectures effectuées par les autres processeurs peuvent alors renvoyer de vielles données. Un exemple classique est celui de deux cœurs qui partagent le même cache ou la même mémoire, mais qui utilisent chacun une file d'écriture (''store buffer''). Imaginons que le premier cœur effectue une écriture, puis une lecture, à des adresses différentes. L'écriture est retardée grâce au tampon d'écriture, alors que la lecture lit directement le cache. Pour le premier processeur, l'écriture s'est réalisée avant la lecture, car pour lui, la fin de l'écriture signifie écrire dans le tampon d'écriture. Mais pour le second cœur, l'ordre s'est inversé : il voit la lecture dans le cache, puis l'écriture dans le cache. La raison est qu'il n'a pas connaissance du contenu du tampon d'écriture de l'autre cœur. Un autre exemple : les techniques de prédiction d'adresse et de valeur posent des problèmes avec les dépendances RAW. Elles permettent à une instruction mémoire de s'exécuter avant une autre, malgré la présence d'une dépendance RAW entre les deux. L'ordre entre les deux instructions change, alors que les autres processeurs ne devraient pas le voir. Et dans les faits, les processeurs qui incorporent ces techniques ont des modèles de consistance mémoire qui ne permettent pas ces optimisations en cas de dépendances RAW. Les deux techniques doivent idéalement être désactivées pour les instructions mémoire qui ont des dépendances RAW. Les programmeurs doivent tenir compte de ce genre de situations, dans des cas assez rares et liés à la programmation bas niveau. Pour cela, ils doivent savoir comment le processeur réorganise les accès mémoire. Concrètement, chaque processeur définit un '''modèle de consistance mémoire''' qui indique quelles optimisations sont activées et non. Il s'agit d'une sorte de contrat entre le logiciel et le matériel : les programmeurs savent comment le processeur va réorganiser les lectures et écritures et peuvent coder leurs programmes en fonction. L'implémentation du système de consistance mémoire dépend grandement de l'architecture, mais des modèles ont été standardisés afin de permettre aux programmeurs de savoir où placer les barrières mémoires. Il s'agit de vrais standards, formalisés, parfois mathématiquement, que des chercheurs étudient sur un plan mathématique et algorithmique. Le modèle préféré des programmeurs est la '''consistance séquentielle''', où les différents processeurs effectuent des accès mémoire dans l'ordre du programme et les accès mémoire simultanés sont interdits. La première contrainte interdit l'usage de mécanismes de désambiguïsation mémoire, la seconde interdit l'usage de caches non-bloquants. La consistance séquentielle a donc un cout en performance assez important, ce qui fait que les processeurs modernes n'utilisent pas la consistance séquentielle. Un modèle plus flexible est le '''''total store ordering''''', qui autorise la présence d'une file d'écriture et l'usage de caches non-bloquants pour les lectures (interdit d'avoir plusieurs écritures simultanées). C'est plus ou moins celui utilisé par les processeurs x86, comme on le verra plus bas. Il existe des modèles de consistance mémoire plus relâchés, appelés des '''modèles de consistance faible''', qui autorisent diverses optimisations de spéculation mémoire, la combinaison d'écriture (fusionner deux écritures à la même adresse en une seule), etc. Ils sont supportés sur les architectures ARM et POWER. ===Les barrières mémoire=== Le changement d'ordre des lectures et écritures peut poser occasionnellement des problèmes avec la programmation pour les architectures multi-processeur ou multicœurs. Il existe des morceaux de code qui ne marchent que si la consistance séquentielle est respectée et il faut faire en sorte qu'ils marchent sur des processeurs avec un modèle de consistance mémoire différent. Pour cela, les processeurs incorporent des instructions appelées '''barrières mémoires''' ou '''''Fences'''''. Elles forcent le processeur à terminer tous les accès mémoire qui précédent dans l'ordre du programme. Certains processeurs possèdent une barrière mémoire pour les lectures et une autre pour les écritures. D'autres processeurs ajoutent des barrières mémoires dédiées à des cas particuliers. Elles permettent de forcer les accès à des données partagées à dérouler correctement. [[File:Fences.png|centre|vignette|upright=2|Fences.]] Leur implémentation est assez simple : elles empêchent l'émission de toute nouvelle instruction mémoire, tant qu'une condition précise n'est pas réunie. Par exemple, la barrière mémoire pour les écritures attend que la file d'écriture soit vidée avant de démarrer le moindre accès mémoire. Un compilateur peut placer les barrières mémoires au bon endroit, avec un peu d'aide du programmeur. Certains langages de programmation permettent d'indiquer au compilateur qu'une donnée doit toujours être lue depuis la mémoire RAM, via le mot-clé volatile. C'est très utile pour préciser que cette donnée est potentiellement partagée par plusieurs processeurs ou manipulable par des périphériques. Les compilateurs peuvent placer des barrières mémoires lors des lectures ou écritures sur ces variables, mais ce n'est pas une obligation. Il arrive aussi que le programmeur doive manipuler explicitement des barrières mémoires. Utiliser l'assembleur est alors une possibilité, mais qui est rarement exploitée, pour des raisons de portabilité. Pour limiter la casse, certains systèmes d'exploitations ou compilateurs peuvent aussi fournir des barrières mémoires explicites, encapsulées dans des bibliothèques ou cachées dans certaines fonctions. ===La modèle de consistance des processeurs x86=== Après avoir vu la théorie, passons maintenant à la pratique. Dans cette partie, on va voir les modèles de consistances utilisés sur les processeurs x86, ceux qu'on retrouve dans les PC actuels. Le modèle de consistance des processeurs x86 a varié au cours de l'existence de l'architecture : un vulgaire 486DX n'a pas le même modèle de consistance qu'un Core 2 duo, par exemple. Ils ont évolués au cours du temps, avec l'arrivée de nouvelles optimisations. De plus, leur formalisation a été progressive, et s'est faite au fur et à mesure. En général, les modèles de consistance des processeurs x86 ont toujours étés assez restrictifs, si on les compare aux autres processeurs. Il s'agit de modèles de type ''total store ordering'' (TSO), au moins dans les grandes lignes. Ils permettent donc d'effectuer certaines lectures en avance, soit parce que les lectures se font à des adresses différentes, soit par utilisation de réacheminement écriture vers lecture. Les possibilités d'exécution en avance des lectures varient suivant le processeur, elles deviennent de plus en plus performantes avec le temps. Le premier modèle de consistance est apparu sur les premiers processeurs x86 et est resté en place sur tous les processeurs de marque Pentium. Les processeurs de l'époque n'incorporaient pas beaucoup d'optimisations, le budget en transistor n'était pas assez conséquent pour ça, la mémoire n'était pas assez lente. Le modèle TSO de l'époque n'autorisaient que peu de lectures anticipées. Une lecture ne peut être exécutée en avance que sous les conditions suivantes : * les écritures contournées se font dans la mémoire cache ; * la lecture doit se faire dans la mémoire RAM ; * les écritures se font à une adresse différente de la lecture ; * aucune transaction avec un périphérique ne doit être en cours. A partir du Pentium 4, les choses changent. Le Pentium 4 est en effet le premier processeur à implémenter des techniques permettant d’exécuter plusieurs processus en parallèle, dont l'Hyperthreading. En conséquence, le modèle de consistance a dû être assoupli pour éviter de perdre bêtement en performance. Il s'agit d'un vrai modèle de type TSO, la majeure partie des contraintes sur les lectures anticipées sont réduites au minimum. Il y a aussi des contraintes supplémentaires pour les instructions dites atomiques, ainsi que pour les instructions qui accèdent aux périphériques ou aux entrées-sorties. Une optimisation séparée du TSO est que les écritures en mémoire peuvent changer d'ordre dans un cas exceptionnel : les écritures effectuées par les instructions de copie mémoire comme REPMOVSD, REPSCACB, ainsi que les instructions SSE MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, MOVNTPD. Les écritures effectuées dans ces instructions peuvent se faire dans un désordre complet (ou presque). Lors du passage au 64 bits, le modèle mémoire des processeurs x86 de l'époque a été formalisé dans un papier d'Intel appelé [https://www.cs.cmu.edu/~410-f10/doc/Intel_Reordering_318147.pdf Intel® 64 Architecture Memory Ordering White Paper], mis à jour en 2008 suite à quelques problèmes techniques. La formalisation du modèle a été d'une grande aide aux programmeurs, qui devaient se débrouiller avec des documentations qui ne formalisaient pas de modèle de consistance précis. Le document décrit un modèle de consistance mémoire portant le nom barbare de ''total lock order + causal consistency” (TLO+CC)'', qui admet plus d'optimisations que le modèle TSO. Mais la description était en réalité légèrement différente du modèle mémoire réellement implémenté sur les processeurs existants, qui utilisaient du TSO, sauf en de rares occasions. Les concepteurs de processeur font souvent cela, à savoir définir un modèle de consistance plus laxiste que réellement implémenté, pour des questions de comptabilité. Ainsi, si les futurs processeurs de la marque implémentent un modèle plus laxiste, les programmes déjà codés continuent de fonctionner. Le modèle décrit dans la documentation laisse de la marge pour le futur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le parallélisme mémoire | nextText=Le parallélisme mémoire }} </noinclude> ecwa7arq9ndkzzr7kd7yts5rywyhp03 774178 774177 2026-10-04T19:46:25Z Mewtow 31375 /* La Load/Store Queue : une file de µops améliorée */ 774178 wikitext text/x-wiki Dans les chapitres précédents, nous avons vu la mal-nommée exécution dans le désordre dans les chapitres précédents, qui devrait plutôt s'appeler l'émission dans le désordre. Elle modifie l'ordre des instructions pour gagner en efficacité, mais cela n'est valable que pour les instructions travaillant sur des registres ! Nous n'avons pas parlé de l'exécution dans le désordre des accès mémoire, car ils sont un peu à part. Et pour comprendre pourquoi, nous allons dédier ce chapitre à la gestion des accès mémoire dans le pipeline. Avant de poursuivre, faisons un rappel rapide sur les dépendances de données. Il faut distinguer deux types de dépendances de données : les dépendances de registres (deux instructions manipulent le même registre), et les dépendances d'adresse (deux instructions manipulent la même adresse). Deux instructions ont une dépendance d'adresse si elles écrivent ou lisent la même adresse mémoire. Des instructions mémoires qui lisent/écrivent des adresses différentes sont indépendantes. Lorsque deux instructions mémoire lisent/écrivent à la même adresse, on parle d''''aliasing mémoire'''. Formellement, l'aliasing mémoire n'est ni plus moins que l'application des dépendances de données aux instructions mémoire. Et comme les autres dépendances de données, il existe plusieurs types d'aliasing mémoire : RAR, RAW, WAR et WAW. Les dépendances RAR ne posent aucun problème, les lectures peuvent changer d'ordre sans aucun problème, tant qu'il n'y a pas d'écritures entre les deux. Par contre, les autres dépendances imposent trois contraintes. * Les dépendances RAW imposent que les lectures ne doivent pas s'exécuter avant une écriture à la même adresse. * Les dépendances WAR imposent que l'on ne peut pas déplacer une écriture avant une lecture à la même adresse. * Enfin, les dépendances WAW font que l'ordre des écritures doit être celui du programme : impossible de changer l'ordre de deux écritures. Les trois contraintes peuvent s'implémenter de plusieurs manières différentes. Comme pour les dépendances de données normales, seules les dépendances RAW sont de vraies dépendances de données. Les deux autres dépendances viennent du fait que l'on utilise la même adresse pour stocker des valeurs différentes. Détecter les cas d'''aliasing'' mémoire est primordial pour gérer la désambiguïsation mémoire, et cela demande de comparer des adresses. S'il existe des techniques similaires au renommage de registre permettent de les faire disparaitre, elles sont très limitées. Diverses optimisations permettent d'émettre des accès mémoire dans le désordre, exactement comme l’exécution dans le désordre le fait pour les autres instructions. Elles utilisent pour cela des ''schedulers'' spécialisées dans les accès mémoire. Il faut vraiment insister sur le fait que l'exécution dans le désordre proprement dite ne se préoccupe pas des micro-opérations mémoire. Pour faire la distinction, on parle de '''désambiguïsation mémoire''' (''memory disambiguation'') pour parler de l'exécution dans le désordre des accès mémoire. Le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. La raison est que la donnée lue est utilisée par d'autres instructions, dépendantes de la lecture. Elles sont donc à la tête d'une chaine de dépendances de données qui peut bloquer le pipeline si elle n'est pas résolue très tôt. Dès qu'une lecture peut être lancée, elle accède directement au cache de données. Par contre, si l'''aliasing'' ne le permet pas (écriture à la même adresse pas encore effectuée) ou que l'adresse à lire n'a pas encore été calculée, la lecture attend son tour. Le problème est que la désambiguïsation mémoire demande de comparer des adresses, pour détecter les dépendances d'adresse. Et cela pose de sérieuses contraintes pour son implémentation. L'exécution dans le désordre normale compare des registres, ce qui est beaucoup plus simple. Les registres utilisés sont directement encodés dans l'instruction elle-même, ce qui fait qu'on peut détecter les dépendances de registre après le décodage. Et elles peuvent même être éliminées par le renommage de registre pour les dépendances WAW et WAR. Par contre, les adresses ne sont pas encodées dans l'instruction elle-même (sauf avec le cas particulier de l'adressage absolu), mais sont lues depuis les registres ou calculées par une unité de calcul. Les adresses ne sont donc connues qu'une fois que l'instruction a attendu suffisamment de temps dans le ''scheduler''. ==L’émission dans l'ordre des accès mémoire== Avant toute chose, faisons quelques rappels sur l'exécution dans le désordre "normale", sans désambiguïsation mémoire. Les micro-opérations renommées sont mises en attente dans un ''scheduler'', le temps que leurs opérandes soient disponibles. Les µops mémoire font cependant exception. ===La file de µops mémoire=== Avec l''''émission dans l'ordre des accès mémoire''', le processeur émet les accès mémoire dans l'ordre du programme. Cela implique que l'unité mémoire n'a pas de ''scheduler'', ni de station de réservation associée. À la place, les micro-opérations mémoire sont placées dans une file de micro-opération dédiée, appelée la '''file de µops mémoire'''. La file de µops mémoire est une mémoire FIFO, dont chaque entrée peut accueillir une µops mémoire. [[File:Unité de gestion des accès mémoires dans l’ordre.png|centre|vignette|upright=2.0|Unité de gestion des accès mémoires dans l’ordre]] Par contre, le processeur utilise des ''schedulers'' pour les unités de calcul. Autant les accès mémoire se font dans l'ordre, autant les instructions arithmétiques/logiques/branchements/autres sont exécutées dans le désordre. En clair, le processeur supporte l'exécution dans le désordre, sauf pour les micro-opérations mémoire. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] Vu de l'extérieur, il faut attendre qu'un accès mémoire soit terminé pour lancer le suivant. Avant d'émettre une micro-opération mémoire, la file de micro-opération (son ''scoreboard'') vérifie si l'unité d'accès mémoire est libre ou non. Si elle est occupée, un accès mémoire est en cours, et on ne peut pas émettre de micro-opération mémoire. Une fois l'accès mémoire terminé, la mémoire ou le cache envoient un signal pour indiquer que l'accès est fini, et c'est au tour d'une nouvelle micro-opération mémoire. L'unité mémoire a un port sur lequel on envoie la micro-opération émise, que ce soit une écriture ou une lecture. Une file de µops implique la ''lecture après émission'', à savoir que les µops mémoire quittent la file de µops, avant de lire les registres adéquats. Il y a donc une connexion entre banc de registre et unité mémoire, comme sur n'importe quel autre processeur. Mais la file de µops n'est pas connectée au banc de registre, ni au réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] L'organisation précédente laisse penser que les calculs d'adresse sont forcément réalisés dans l'unité mémoire. Mais ce n'est pas une obligation. Il est parfaitement possible de scinder une µops mémoire en deux : une µop mémoire proprement dite et une µop de calcul d'adresse. La liaison entre les deux doit cependant se faire en utilisant un registre. L'adresse calculée est enregistrée dans un registre d'adresse "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. ===La file d'écriture : une FIFO de mise en attente des écritures=== L'unité mémoire exécute une µops mémoire à la fois. Avec un détail particulier : les écritures doivent attendre que toutes les instructions se terminent, avant de pouvoir s'exécuter. Sans ça, les exceptions précises sont impossibles, la prédiction de branchement l'est aussi et l'exécution dans le désordre aurait des conséquences fâcheuses (en exécutant des écritures en avance). Précisément, les écritures doivent respecter deux contraintes : s'exécuter dans le tout dernier étage du pipeline, s'exécuter dans l'ordre du programme. Il serait possible de bloquer l'émission des écritures tant que le ROB ne donne pas son accord. Mais ce n'est pas du tout ce qui est fait. A la place, les écritures sont mises en attente dans une mémoire FIFO, appelée la '''file d'écriture''', ou encore la ''Write Queue''. Les écritures sont mises en attente, jusqu'à ce que le ROB leur donne le okay pour transfer l'écriture en mémoire RAM. Nous en avions déjà parlé dans le chapitre sur le pipeline, dans la section sur les exceptions précises. Mais quelques rappels ne feront pas de mal. L'écriture est insérée dans la file d'écriture lorsqu'elle est émise, elle en sort à l'étage de ''Writeback'' si tout se passe bien. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente, faites à tort. De plus, la file d'écriture est une mémoire FIFO, qui mémorise les écritures dans l'ordre du programme. En effet, les écritures sont ajoutées dans la file d'écriture lorsqu'elles sont émises, et elles sont émises dans l'ordre du programme. [[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Write Queue sur un pipeline fixe]] La file d'écriture garantit que les écritures sont dans l'ordre du programme. Et cette propriété élimine totalement les dépendances WAW. Le fait que les écritures se font à la fin du pipeline élimine quant à elle les dépendances WAR. Il ne reste plus qu'à gérer les dépendances RAW, celles où une lecture lit une donnée écrite par une autre instruction. Une telle situation survient, car les écritures sont mises en attente pendant un certain temps, la donnée écrite peut être lue pendant ce laps de temps. La lecture ne peut pas lire la donnée dans le cache, car la donnée à lire n'y est pas encore enregistrée. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire, mais il existe. Il se manifeste surtout sur les processeurs avec peu de registre, où des données doivent régulièrement être déplacées temporairement en mémoire RAM. Pour éviter cela, une solution consiste mettre en attente les lectures si une écriture est en attente. Les lectures sont maintenues dans la file de µops tant que la file d'écriture n'est pas vide. La file de µops mémoire est suivie d'un circuit de détection des dépendances, un '''''scoreboard'' mémoire''' qui est assez simple. L'unité mémoire fournit un bit qui indique si la file d'écriture est vide ou non, ainsi qu'un second bit qui indique qu'elle est vide. Les deux ne sont pas compliqués à générer, ils font partie intégrante de l'interface de toute mémoire FIFO digne de ce nom. Le ''scoreboard'' mémoire utilise ces deux bits pour savoir s'il peut émettre une micro-opération mémoire. Il n'émet pas de lecture tant que la file d'écriture n'est pas vide. Il peut par contre émettre des écritures tant qu'elle n'est pas pleine. Une meilleure solution serait d'interdire les lectures, mais seulement en cas de dépendance mémoire détectée. Et c'est là qu'intervient la technique du '''''load bypassing'''''. Elle autorise une lecture, si elle n'a aucune dépendance avec les écritures mises en attente dans la file d'écriture. Elle doit être mise en attente dans le cas contraire. Pour l'implémenter, il faut comparer l'adresse à lire avec toutes les adresses dans la file d'écriture. La lecture est exécutée immédiatement si aucune correspondance n'est trouvée, mais elle est mise en attente dans le cas contraire. Pour cela, la file d'écriture est modifiée et devient un mélange entre FIFO et mémoire associative. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'' : la lecture doit être bloquée. Sinon, c'est un défaut de ''Write Queue'', la lecture accède au cache, là où se trouve la donnée à lire. Dans le chapitre sur le contournement, nous avons parlé d'une forme de contournement qui corrige ce problème. Les lectures peuvent lire la donnée depuis la file d'écriture si besoin, ce qui renvoie la donnée adéquate. Pour cela, les lectures consultent à la fois le cache et la file d'écriture, comme dit plus haut. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'', ou encore de '''réacheminement écriture-vers-lecture'''. [[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]] La lecture est mise en attente dans l'unité mémoire, vu que c'est elle qui fait les comparaisons d'adresse. Du point de vue extérieur, le ''scoreboard'' mémoire ne voit pas s’il y a eu succès dans la file d'écriture ou non. Il voit juste que l'unité mémoire est occupée et ne peut pas accepter de nouvelle lecture. Elle reste occupée durant quelques cycles, en cas de défaut de ''Write Queue'', le temps de faire la lecture dans le cache. Par contre, en cas de succès de ''Write Queue'', elle reste occupée tant que la lecture n'a pas donné de résultat, ce qui inclue le temps de mise en attente. Les processeurs haute performance implémentent tous le ''Store to load Forwarding'', mais quelques processeurs basse performance ne le font pas. Ils se contentent du ''load bypassing'', qui offre des performances correctes pour un cout en matériel bien plus modeste. Pour donner un exemple, la micro-architecture Jaguar d'AMD était dans ce cas. Elle a été utilisée sur les consoles de jeu de 8ème génération, comme la Xbox One et la Playstation 4. Les processeurs haute performance préférent utiliser plus de circuits pour implémenter le ''Store to load Forwarding'', mais l'implémentation n'est souvent pas complète. En effet, l'implémentation est souvent simple et ne prend en compte que des lectures/écritures à des adresses identiques, sans compter que la taille des données doit aussi être identique. Une écriture d'un entier de 32 bit sera contournée et envoyée à une lecture de 32 bits à la même adresse. Mais la lecture lit seulement 16 bits sur les 32 écrits, le contournement peut ne pas marcher. Concrètement, ce cas précis fonctionnera. Mais le cas inverse, avec une lecture lisant plusieurs écritures de taille plus petite, ne marchera pas sur tous les processeurs. Ne parlons pas du cas où les données écrites sont à cheval sur deux lignes de cache. ==L'exécution anticipée des calculs d'adresse== L'exécution dans le désordre fonctionne mieux si les lectures sont exécutées le plus tôt possible. Les lectures lisent des données qui sont utilisées par d'autres instructions. Si la lecture tarde, les autres instructions sont mises en attente. Exécuter les lectures le plus tôt possible est donc crucial. Mais comment faire ? Un moyen pour ça est de faire en sorte que les adresses à lire/écrire soient connues le plus tôt possible. Mais cela implique que les calculs d'adresse ne sont pas fait en même temps que l'accès mémoire. Pour cela, il est possible de séparer les µops mémoire en deux micro-instructions : une pour le calcul d'adresse et l'autre pour les accès mémoire. On parle de '''calcul anticipé des adresses'''. Avant de poursuivre, un minimum de terminologie. Nous parlerons d'AGU pour désigner les unités de calcul d'adresse, ce qui est une abréviation de ''Adresse Generation Unit''. Nous parlerons aussi de '''µops AGU''' pour parler des µops de calcul d'adresse. Le terme est de moi, il est assez parlant : "µops exécutés dans les AGUs". Nous utiliserons ce terme pour les séparer des µops entières classiques, comme les additions/soustractions/multiplications arithmétiques, les décalages/rotations, etc. Le calcul anticipé des adresses est utilisé avec ou sans désambiguïsation mémoire. Elle a des avantages avec et sans. Sans, elle calcule les adresses en avance, ce qui fait que des lectures/écritures mises en attente ont leurs opérandes prêtes plus rapidement. Avec, cela permet de connaitre les adresses en avance, ce qui permet de détecter plus rapidement les dépendances d'adresse. Mais laissons cela pour plus tard. ===La ''Load/Store Queue'' : une file de µops améliorée=== Implémenter cette technique demande de faire deux choses. Premièrement, il faut faire les calculs d'adresse en-dehors de l'unité mémoire, soit dans une ALU entière, soit dans une AGU dédiée. Deuxièmement, les adresses calculées doivent être mises en attente quelque part, et être liées facilement à la µop mémoire associée. Pour le second point, la file de µops mémoire parait être un bon candidat, mais n'est pas exactement la solution. Une file de µops au sens strict ne mémorise pas les opérandes des µops, donc pas d'adresse. Cela implique la lecture après émission, ce qui veut dire que l'adresse à lire/écrire est lue depuis les registres une fois que la µops sort de la file de µops (idem pour la donnée à écrire). En pratique, cette implémentation rend difficile de faire les calculs d'adresse ailleurs que dans l'unité mémoire. Heureusement, il est possible d'adapter la file de µops pour gérer la lecture avant émission. L'idée est qu'elle mémorise, pour chaque µop mémoire, l'adresse à lire/écrire et d'autres informations. Il ne s'agit donc plus vraiment d'une file de µops mémoire mais quelque chose d'autre, une sorte d'intermédiaire entre une station de réservation et une file de µops. D'un côté, elle fonctionne comme une file de µops car elle émet les µops dans l'ordre du programme. De l'autre, elle mémorise les opérandes des µops mémoire, ce qui en fait une pseudo-station de réservation. Une telle structure est appelée une '''''Load/Store Queue''''' (LSQ). Une LSQ contient plusieurs entrées, chaque entrée pouvant accueillir une µops mémoire et ses opérandes. Une entrée contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures. Les deux derniers champs sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. Elle contient aussi des bits de validité, qui indique si l'adresse ou la donnée sont disponibles, ou si le champ associé est vide. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2|Load Store Queue unifiée]] La LSQ doit faire le lien entre une adresse calculée par l'AGU, et l'entrée associée de la LSQ. Pour cela, l'unité d'émission associe un '''identifiant LSQ''' à une µops mémoire. L'identifiant est généré à l'émission. Aussi bien la µops de calcul d'adresse que a µop mémoire ont toutes deux le même identifiant. L'identifiant est propagé avec la µop de calcul d'adresse, comme tout signal de commande. En sortant de l'AGU, l’identifiant est transmis à la LSQ en même temps que l'adresse. La LSQ regarde quelle entrée a cet identifiant, et mémorise l'adresse dedans. La gestion des données à écrire se fait elle avec le système de contournement normal. La LSQ sait dans quel registre se trouve la donnée à écrire, elle surveille juste le système de contournement pour détecter quand la donnée est disponible. L'implémentation impose que toutes les instructions mémoire sont découpées en deux µops : une µop pour calculer l'adresse, et une µop mémoire. C'est un défaut assez important, car une instruction mémoire prendra deux places dans le ROB, dans les stations de réservation, etc. Vu que les instructions mémoire sont très fréquentes, la capacité du ROB/autres est alors fortement réduite. Mais diverses optimisations de macro-fusion permettent de réduire les problèmes. Mais nous laisson cela au chapitre sur les processeurs superscalaires. ===Le contournement des adresses mémoire avec une LSQ=== Avec une LSQ, les registres sont lus quand la µop mémoire est insérée dans la LSQ, si elles sont disponibles. Si elles ne sont pas encore disponibles, elles seront envoyées par le système de contournement plus tard. Cela implique qu'il y ait un système de contournement, qui relie les ALUs entières à la LSQ. Les ALUs envoient leur résultat à la LSQ, qui place alors l'adresse calculée dans la bonne entrée, la bonne "µop". [[File:Load-Store Queue.png|centre|vignette|upright=2|Load-Store Queue]] Une autre solution utilise des AGU spécialisées. L'avantage est que cela simplifie grandement le réseau de contournement : seules les AGU sont connectées à la LSQ. Un autre avantage est lié à l'adressage de la destination. En utilisant des ALU entières, il faut préciser que le résultat est à destination de la LSQ, pour configurer le réseau de contournement. Avec des AGU,, l'adresse calculée est envoyée automatiquement à la LSQ, pas besoin de préciser la destination. [[File:ALU et LSQ.png|centre|vignette|upright=2|AGUs et Load-Store Queue.]] Dans les deux cas, les µops AGU sont alors considérées comme des opérations entières un peu spéciales, gérées par le système d'exécution dans le désordre. Elles peuvent être émises dans le désordre, mais la LSQ se chargera de faire les accès mémoire dans l'ordre. Il y a donc calcul des adresses dans le désordre, mais accès mémoire dans l'ordre. ==Les ''schedulers'' mémoire unifiés== Avec la LSQ précédente, les µops mémoire s'exécutent dans l'ordre. Il arrive que des lectures prêtes soient mises en attente, parce qu'une lecture précédente attend ses opérandes. Et ce retard sur les lectures se répercute sur les instructions lecture-dépendantes, donc sur le reste du pipeline. Mais avec l'implémentation qui va suivre, il est possible exécuter les lectures dans le désordre tant que les conditions d'''aliasing'' sont respectées. L'implémentation améliore la LSQ pour en faire un ''scheduler'' spécialisé dans les µops mémoire. Il sera appelé le '''''scheduler'' mémoire''' dans ce qui suit, mais il est souvent appelé la LSQ dans certains documents. [[File:Processeur avec émission dans le désordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans le désordre des accès mémoire]] Lorsqu'une micro-opération mémoire est émise, elle est insérée dans le ''scheduler'' mémoire. Si l'adresse à lire/écrire est disponible, elle est immédiatement lue depuis les registres, idem pour une éventuelle donnée à écrire. Sinon, la micro-opération attend dans le ''scheduler'' mémoire que son adresse soit calculée par le processeur. ===Les calculs d'adresse avec un ''scheduler'' mémoire=== Le ''scheduler'' mémoire détecte des dépendances d'adresse entre instructions. Pour cela, il compare toutes les adresses à lire/écrire. Il mémorise donc les dites adresses, ce qui en fait une station de réservation. Et cela implique que ces adresses ont été calculées ailleurs, dans une ALU entière, ou dans une AGU séparée de l'unité mémoire. En somme, c'est le même problème qu'avec la LSQ vue plus haut. Là encore, l'implémentation la plus simple effectue les calculs d'adresse dans les ALU entières. La sortie de toutes les ALU entière est reliée au ''scheduler'' mémoire. [[File:Contournement pour la file de µops mémoire.png|centre|vignette|upright=2|''scheduler'' mémoire]] L'autre implémentation utilise des unités de calcul d'adresse dédiées, afin de simplifier le réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse via AGU.png|centre|vignette|upright=2|ALU et ''scheduler'' mémoire]] ===La détection des dépendances mémoire=== Le ''scheduler'' mémoire intègre un système de détection des dépendances, qui est bien différent de celui d'une station de réservation normale. Les ''schedulers'' mémoire comparent non pas des noms de registres, mais des adresses/opérandes. Pour détecter les dépendances, le ''scheduler'' mémoire compare les adresses à lire/écrire. Pour les µops dont l'adresse est inconnue, le cas dépend de l'implémentation. La version la plus conservatrice suppose qu'elle a des dépendances possibles avec toutes les autres. Chaque micro-opération en attente compare son adresse avec celle de toutes les micro-opérations précédentes. Ou presque, la comparaison ne doit tenir compte que des écritures, et de celles situées avant dans l'ordre du programme. Le ''scheduler'' mémoire doit donc mémoriser les instructions dans l'ordre du programme, tout en faisant des comparaisons d'adresse. Ça en fait une sorte hybride entre mémoire associative et mémoire FIFO. Nous avions vu dans le chapitre "L'exécution dans le désordre" qu'il y a plusieurs manières pour implémenter une station de réservation : avec une mémoire associative, avec préplanification, avec des matrices de dépendances. Le problème est qu'on ne peut pas utiliser une mémoire associative pour le ''scheduler'' mémoire, qui utilise des comparateurs pour comparer les noms de registres/adresses. En effet, la détection des dépendances mémoire demande de faire beaucoup de comparaisons : chaque adresse est comparé à toutes les autres. Et les adresses faisant facilement 32 à 64 bits, les comparateurs sont bien plus imposants que ceux qui comparent des noms de registres de 5-10 bits. Le cout en matériel serait trop élevé, de même que le temps de calcul des dépendances. À la place, on utilise des matrices de dépendances. La détection des dépendances mémoire utilise précisément deux matrices de ce type : une matrice de disponibilité, et une matrice de dépendance. Ces matrices forment une espèce de tableau carré, organisé en lignes et en colonnes. L'instruction dans la énième entrée se voit attribuer la énième ligne et la énième colonne. À l'intersection d'une ligne et d'une colonne, on trouve un bit qui indique si l'instruction de la ligne et celle de la colonne ont une dépendance. La '''matrice de disponibilité''' permet de bloquer l'exécution des lectures/écritures tant qu'une instruction précédente n'a pas calculé son adresse, tant qu'on ne peut pas vérifier les dépendances. Lorsqu'une instruction est ajoutée dans le ''scheduler'' mémoire, elle met tous les bits de la colonne attribuée à 1 si l'adresse à lire/écrire est inconnue, puis les met à 0 une fois l'adresse calculée. Une instruction ne peut pas démarrer si, sur la ligne qui lui est attribuée, se trouve un 1. Plus précisément, si on trouve un 1 pour les instructions plus anciennes, ce qui fait qu'une partie de la matrice est ignorée. Les bits notés X dans le tableau suivant sont ignorés, car ils précisent la disponibilité des opérandes d'un opérande ultérieure, ce qui est inutile. En clair, les calculs d'adresse modifient les colonnes, et on vérifie les lignes pour autoriser l'exécution. {|class="wikitable" |- ! Instructions mémoire dans la LSB, triées de la plus ancienne à la plus jeune ! 0 !! 1 !! 2 !! 3 !! 4 !! 5 !! 6 |- ! 0 | 1 || X || X || X || X || X || X |- ! 1 | 0 || 0 || X || X || X || X || X |- ! 2 | 0 || 1 || 0 || X || X || X || X |- ! 3 | 0 || 1 || 1 || 1 || X || X || X |- ! 4 | 0 || 0 || 0 || 0 || 1 || X || X |- ! 5 | 0 || 0 || 1 || 0 || 0 || 1 || X |- ! 6 | 1 || 1 || 1 || 1 || 1 || 1 || 0 |} Les '''matrices de dépendances''' fonctionnent sur le même principe, mais déterminent les dépendances mémoire. Lorsque le processeur a calculé une adresse, il la compare avec les adresses à lire/écrire déjà dans le ''scheduler'' mémoire. Il met alors à jour les bits de la colonne de la matrice de dépendance : le bit à l'intersection d'une ligne et d'une colonne est mis à 0 si les deux instructions n'ont pas de dépendances, à 1 si elles en ont une. Une instruction mémoire est exécutée si tous les bits de sa ligne sont à 0. Là encore, on ne tient compte que des bits pour les instructions plus anciennes, la matrice a encore une fois une forme triangulaire, comme pour la matrice de disponibilité. Une optimisation scinde le ''scheduler'' mémoire en deux structures, aux rôles légèrement différents. La première gère la disponibilité des opérandes, la seconde gère seulement les dépendances mémoire. La première est une station de réservation où les instructions sont mises en attente tant que leurs opérandes ne sont pas prêts. La seconde met en attente des micro-opérations dont les opérandes sont prêts, mais qui doivent attendre pour une autre raison : dépendance mémoire, cache occupé, etc. L'avantage est qu'on remplace une grosse station de réservation par deux plus petites, mais l'intérêt est surtout relié au système de détection des dépendances. Sans cette optimisation, on a deux matrices énormes reliées à une ''Load/Store Queue'' unique. Avec la séparation, la matrice de disponibilité est reliée uniquement à la première station de réservation, alors que la matrice de dépendance est reliée uniquement à l'autre. Les deux matrices sont donc plus petites, car reliées à deux stations de réservation mémoire plus petites. ==L'exécution spéculative des accès mémoires== Pour rappel, le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. Les données lues sont en effet, le départ d'une série d'instructions dépendantes, généralement des instructions arithmétiques, mais parfois d'autres lectures. Quand on manipule des structures de données, il n'est pas rare qu'une lecture lise un pointeur-adresse utilisé par une autre lecture. Les techniques de désambiguïsation mémoire que nous avons vu précédemment sont dites conservatrices, à savoir qu'elles ne changent pas l'ordre de deux instructions mémoire si elles ont une dépendance d'adresse. Elles exécutent une lecture le plus tôt possible, mais seulement si on sait que celle-ci n'aliase pas d'écritures précédentes. Et cela demande de connaitre les adresses des écritures précédentes dans l'ordre du programme. Si une lecture est précédée par une ou plusieurs écritures, les adresses à écrire doivent être connues. Il se peut en effet qu’une écriture dont on ne connait pas l'adresse aliase la lecture. La garantie est liée à la présence de la file de µops mémoire ou la file de µops STORE. Si une seule écriture a une adresse inconnue, elle reste dans la file de µops mémoire et bloque l'émission de toutes les autres micro-opérations mémoire, lecture comme écriture. Mais il existe des techniques dites spéculatives, qui modifient l'ordre des accès mémoire sans se soucier de leurs dépendances. Elles émettent les lectures dès que leurs opérandes sont prêts, sans se préoccuper de l'aliasing avec une écriture précédente. Pour éviter tout problème, le processeur vérifie si aucune dépendance mémoire n'est violée et corrige le tir dès qu'une violation est détectée. Le processeur vérifie s'il a fait une erreur de prédiction, en vérifiant les adresses à écrire ou lire. En cas d'erreur, le processeur fait comme lors d'une prédiction de branchement ratée : il remet le pipeline en ordre et ré-exécute les accès mémoire dans le bon ordre. Il s'agit d'un cas particulier d’'''exécution spéculative''', au même titre que la prédiction de branchement. Toutes les techniques de ce genre exécutent des lectures dans le désordre. L'avantage est que cela permet d'exécuter les lectures en avance. Vu que la donnée lue est l'opérande d'autres instructions, les exécuter en avance fait prendre de l'avance à un paquet d'instructions. Mais il faut comparer cet avantage au risque de mauvaise prédiction. Et aussi bizarre que cela puisse paraître, cela donne de bons résultats. Il faut dire que les situations où une lecture relit une donnée tout juste écrite sont rares : même pas 2 à 3 % des lectures. Les dépendances mémoire sont donc très rares, les cas d'''aliasing'' sont vraiment limités. En termes de cout en transistors, le hardware qui vérifie les dépendances mémoire reste globalement le même à peu de choses près, il est juste déplacé dans le pipeline et adapté à sa nouvelle fonction. ===La file de lectures=== La technique que nous allons voir émet les lectures dans l'ordre, ce qui simplifie énormément la gestion des dépendances. Les lectures sont émises dans l'ordre, les écritures aussi. Par contre, l'ordre entre lectures et écritures n'est pas respecté. Une lecture peut passer avant une écriture sans problèmes, voire avant plusieurs écritures d'un seul coup. Une telle lecture exécutée en avance d'une autre écriture est appelée une '''lecture spéculative'''. Une lecture spéculative peut donc sauter devant une ou plusieurs écritures, qui seront appelées des '''écritures sautées'''. Une écriture sautée est une précédant une lecture spéculative, dans l'ordre du programme. Évidemment, il se peut qu'une écriture sautée écrive la donnée qui est lue par la lecture spéculative. Il y a alors une violation de dépendance mémoire. Mais le processeur suppose qu'il n'y a pas de dépendantes de ce type et émet la lecture en avance. Rassurez-vous, il surveille quand même la situation, pour tout annuler ensuite s'il détecte une violation de dépendance mémoire. J'ai dit plus haut que le processeur émet les lectures dans l'ordre, ainsi que les écritures dans l'ordre. Pour cela, la LSQ est scindée en deux files de µops : une pour les µops LOAD et une autre pour les µops STORE. Les cours d'architecture des ordinateurs nomment souvent ces deux files en utilisant les termes de ''file de STORE'' (''store queue'') et de ''file de LOAD'' (''load queue''), mais il y a un risque de confusion avec la file d'écriture vue précédemment, et la file de lecture qu'on introduira dans la suite. Dans ce qui suit, nous parlerons de '''file de µops LOAD''' et de '''file de µops STORE''', pour éviter toute confusion. Les deux files sont chacune connectées à l'unité mémoire, via un port dédié. L'unité mémoire a donc un port pour les lectures, et un port pour les écritures. Le port d'écriture est relié à la file d'écriture, le port de lecture est directement relié au cache de données. Il faut noter qu'une unité mémoire à deux ports n'est pas si bizarre que cela. En effet, le cache de données a lui aussi deux ports, dans la majorité des cas, avec un port de lecture et un port d'écriture. [[File:Load Ordering, Store Ordering.jpg|centre|vignette|upright=2|Exécution dans le désordre des lectures.]] Maintenant, voyons comment sont détectées les violations de dépendances mémoire. Les dépendances qui peuvent être violées dans ce cas de figure se font entre une écriture sautée et une lecture spéculative. Si une lecture spéculative lit une donnée qui est écrite par une écriture sautée, alors une dépendance RAW a été violée. Pour les détecter, le processeur garde une trace des lectures spéculatives; tant que les instructions précédentes ne sont pas terminées. Pour cela, il utilise une file d'attente, qui mémorise les lecture spéculatives. Nous l'appellerons la ''file de vérification des lectures'', ou encore la '''file de lectures'''. Elle est une sorte d'équivalent de la file d'écriture, mais pour les lectures. Les lectures sortent de la file µops LOAD dès que leurs adresses sont calculées, sans tenir compte des dépendances mémoires. Elles sont alors ajoutées à la file de lectures et s'exécutent immédiatement, de manière spéculative. Pour vérifier qu'il n'y a pas d'erreur de spéculation, le processeur conserve l'adresse de la lecture effectuée dans la file de lectures. Les lectures quittent la file de lecture quand toutes les instructions précédentes dans l'ordre du programme sont terminées, à savoir tant quand elles ont quitté le tampon de réordonnancement/ROB. Notons que la file de vérification des lectures contient les lectures dans l'ordre du programme. En effet, les lectures sont accumulées en sortant de la file de µops LOAD, où elles sont triées dans l'ordre du programme. Et cela permet de gérer les erreurs de prédiction parfaitement, en annulant les instructions suivant une erreur de prédiction. [[File:Spéculation avec une file de lecture.jpg|centre|vignette|upright=2.5|Spéculation avec une file de lecture.]] Reste à détecter les erreurs de prédiction, ce qui peut être fait avec deux méthodes. La première détecte les violations de dépendances mémoire à chaque écriture. L'idée est que chaque écriture vérifie si une lecture anticipée n'a pas lue la donnée écrite en avance. Pour cela, quand une écriture est émise, elle consulte la file de lecture pour voir s'il y a un conflit d'adresse. La vérification fait cependant gaffe à ne prendre en compte que les lectures situées après l'écriture dans l'ordre du programme. Précisément, elle envoie l'adresse à écrire à la file de lecture et cherche si une lecture ultérieure a eu lieu à la même adresse. Si c'est le cas, cela signifie qu'une lecture anticipée aurait du lire la donnée actuellement écrite, mais que la lecture a été exécutée trop en avance. La lecture n'a pas lu la bonne donnée, il y a eu une violation de dépendance mémoire. Pour faire cette vérification, la file de lecture est un mélange entre FIFO et cache/mémoire associative, sur le même modèle que la file d'écriture. La différence est que la consultation de la file de lecture ne sert pas à faire du contournement, mais à détecter les dépendances. Quand une dépendance est détectée, l'écriture se fait normalement. Par contre, la lecture marquée comme invalide dans la file de lecture. L'exécution du programme se poursuit, jusqu'à ce que la lecture arrive à l'étape de ''commit'', à savoir que le ROB lui dit que les instructions précédentes sont terminées sans encombre. Là, la lecture étant invalide, le processeur déclenche une procédure de réparation, qui vide partiellement le pipeline de toutes les instructions qui suivent la lecture. La lecture est de plus ré-executée, afin de lire la bonne donnée. Avec la seconde méthode, les violations de dépendance mémoire sont détectées au moment où la lecture quitte à la file de lectures et le ROB. L'idée est de comparer la donnée lue de manière spéculative avec la donnée lue de manière non-spéculative. Pour faire la comparaison, la lecture spéculative copie la donnée lue dans la file de lecture. Quand la lecture quitte la file de lecture et le ROB, la lecture est ré-exécutée et la donnée relue. Le processeur compare alors la donnée lue à ce moment avec la donnée mémorisée dans la file de lecture. Si la donnée est différente, alors il y a eu erreur de violation de dépendance mémoire. Évidemment, relire une donnée n'est pas gratuit. Il est possible de limiter son impact en performance en dédiant un port de lecture sur le cache rien que pour ça. Mais le cout en circuit est comparable à l'usage d'une FIFO associative de la technique précédente. Mais quoi qu'il en soit, le cache doit permettre des lectures en un seul cycle, sans quoi la méthode ne marche pas vraiment. ===La prédiction de dépendances mémoires=== Certains processeurs essayent de prédire si deux accès mémoires sont dépendants : ils incorporent une unité qui va fonctionner comme une unité de prédiction de branchement, à la différence qu'elle prédit les dépendances mémoires. Si cette unité prédit que deux accès mémoires sont indépendants, le processeur les exécute dans le désordre, et les exécute dans l'ordre du programme dans le cas contraire. Il faut prendre en compte les erreurs de prédiction, ce qui est fait comme pour les mauvaises prédictions de branchement : on vide le pipeline. Beaucoup de techniques de prédiction des dépendances mémoire ont été inventées et en faire une revue exhaustive serait long et fastidieux. On pourrait citer les ensembles colorés (''color sets''), et bien d'autres techniques. Mais nous n'allons parler que des techniques les plus élémentaires. Quoi qu’il en soit, la recherche sur le sujet est assez riche, comme toujours quand il s'agit d'architecture des ordinateurs. On peut améliorer cette technique en mémorisant les instructions qui ont causé une mauvaise prédiction de dépendance mémoire. La prochaine fois qu'on exécute ces instructions, on sait qu'il y a de grandes chances qu'il se produise une erreur de prédiction, ce qui pousse à ne pas les exécuter de manière spéculative. On peut pour cela réutiliser les techniques de prédiction de branchement, tels des compteurs à saturations, mis à jour en cas d'erreurs de prédiction de dépendances mémoire. Dans tous les cas, on trouve un cache, équivalent au ''branch target buffer'', qui mémorise les instructions fautives (leur ''Program Counter''), avec d'autres informations comme les adresses des instructions dépendantes. La première classe de techniques du genre consiste à mémoriser les lectures qui ont causé une violation de dépendance, tandis que l'autre ne mémorise que les écritures. Cette dernière est l'approche utilisée par le '''cache de barrières d’écriture''' (store barrier cache). Enfin, nous allons conclure avec une dernière technique : celle des '''ensembles d’écritures''' (store sets). Cette technique est capable de gérer le cas où une lecture dépend de plusieurs écritures : le processeur mémorise l'ensemble des écritures avec lesquelles la lecture a déjà eu une dépendance. Cet ensemble d'écritures associées à une lecture est appelé tout simplement un ensemble d’écritures, et reçoit un identifiant (un nombre) par le processeur. Cette technique demande d'utiliser deux tables : * une qui assigne un identifiant d’ensemble d’écritures à l'instruction en cours ; * une autre qui mémorise les ensembles d’écritures sous la forme d'une liste chainée : le début de la liste correspond à l'écriture la plus ancienne de l’ensemble. Cela permet d'obtenir l'écriture la plus ancienne dans cet ensemble d’écritures directement. Quand le processeur détecte une violation de dépendance entre une lecture et une écriture, elle ajoute l'écriture dans l’ensemble d’écritures adéquat. Le déroulement d'une lecture demande d'accéder à la première table pour récupérer l'identifiant de l’ensemble d’écritures, et l'utiliser pour adresser la seconde table. S'il y a dépendance, cet accès renvoie l'écriture fautive en cas de dépendance. Quand une écriture est envoyée à l'unité mémoire, celle-ci va accéder à la table de correspondances de la même manière qu'une lecture, et va récupérer l'identifiant de l’ensemble d’écritures auquel elle appartient, identifiant qui est utilisé pour vérifier s'il y a une écriture en cours d’exécution. Si c'est le cas, cela signifie que l'écriture en cours d’exécution doit s’exécuter avant l'écriture qui a consulté la table : cette dernière est mise en attente. ==La prédiction d'adresse et de valeur== D'autres techniques de prédiction mémoire plus élaborées que les précédentes existent. Il s'agit de la prédiction d'adresse et de la prédiction de valeur. Leur nom trahit ce qu'elles font. La première technique tente de prédire l'adresse d'une lecture/écriture, alors que la seconde tente carrément de prédire quelle sera la donnée lue. Il y a bien de la recherche académique sur le sujet, mais il s'agit de techniques assez osées, dont on voit mal comment elles pourraient fonctionner. L'efficacité de cette technique a été étudiée grâce à des simulateurs. Suivant les études ou les programmes, on trouve des résultats qui varient pas mal. Dans certains cas, la performance baisse un peu, et dans d'autres, on peut avoir des gains de plus de 10% ! Mais dans des cas normaux, on trouve des gains de 4-5% environ. Ce qui est tout de même pas mal à l'heure actuelle. Cependant, en Janvier 2025, à l'heure où j'écris ces lignes, il a été découvert la première utilisation de ces deux techniques dans un processeur commercial. La découverte fait suite à la publication de deux vulnérabilités matérielles sur les CPU Apple M2 et M4 et quelques CPU ARM, qui font justement usage de ces deux techniques. Les deux vulnérabilités, appelées SLAP et FLOP, sont assez similaires aux attaques Spectre et Meltdown, sauf qu'elles attaquent non pas la prédiction de branchement, mais la prédiction d'adresse et de valeur du CPU. ===La prédiction d'adresse=== La prédiction d'adresse tente de prédire l'adresse d'une lecture à l'avance, ce qui permet de lancer les lectures en avance. Si la prédiction est correcte, on gagne quelques cycles qui permet aux unités de calcul de faire leurs calculs plus en avance, sans compter que les mécanismes de désambiguïsation mémoire fonctionnent plus efficacement. Si jamais cette adresse est connue plus tôt, on détecte les dépendances plus rapidement et on peut agir en conséquence. Des variantes de ces unités de prédiction d'adresse sont utilisées pour précharger des données depuis le cache de donnée. Et leur efficacité est assez bonne, voire excellente dans certains cas. La différence est que la prédiction d'adresse est utilisée pour prédire les adresses à lire depuis le cache vers les registres, ce qui est le sujet de cette sous-partie. La prédiction d'adresse est réalisée par une sorte de cache, qui associe à chaque instruction la prochaine adresse à lire. Nous appellerons ce cache la '''table de prédiction d'adresse'''. Pour faire l'association instruction <-> adresse lue/écrite, le tag du cache utilise l'adresse de l'instruction (le ''program Counter''), alors que la ligne de cache associée contient l'adresse prédite. Son contenu est utilisé pour faire une prédiction. Lorsqu'une lecture est émise, la table est consultée pour récupérer en avance l'adresse à lire, avant que celle-ci soit calculée par l'ALU. La lecture est alors exécutée en avance, avant que la vraie adresse soit connue, en utilisant l'adresse prédite. Reste qu'il faut des circuits autour de cette table de prédiction d'adresse, pour déterminer si la prédiction est correcte, ainsi que pour remplir cette table avec une adresse prédite. Pour cela, il y a un circuit de prédiction d'adresse dédié. Il récupère les instructions qui quittent le ROB ou la file des lectures mémoire, afin de vérifier les lectures qui viennent de se terminer/''commit'' et qui quittent le pipeline. Il peut contenir une '''table d'apprentissage''', qui mémorise des informations nécessaires pour faire des prédictions d'adresse. Par exemple, un historique des dernières adresses lues pour les lectures les plus récentes, ou un historique des adresses des lectures. La table est aussi utilisée pour déterminer si les prédictions effectuées étaient correctes ou non. Pour cela, il est possible de réutiliser les techniques vues dans le chapitre sur la prédiction de branchement, notamment des compteurs à saturation. Pour la prédiction de branchement et les autres formes de prédiction mémoire, les table d'apprentissage et pour les instructions/adresses sont fusionnées en une seule. Mais ici, il est nécessaire de les séparer pour une raison assez particulière : éliminer la pollution du cache dans la table de prédiction d'adresse. On ne peut pas allouer une ligne de cache pour chaque nouvelle instruction de lecture exécutée. Seule une minorité des lectures profitent de la prédiction d'adresse. Il est nécessaire de filtrer les lectures pour ne conserver que celles qui profitent de la prédiction d'adresse, ce qui est possible plus facilement en séparant la table d'apprentissage et les compteurs à saturation de la table de prédiction d'adresse. Une première méthode consiste à prédire que la lecture lit toujours la même adresse, ce qui lui vaut le nom de '''prédiction d'adresse constante'''. On pourrait se demander pourquoi une telle situation surviendrait. La raison est qu'il arrive que les compilateurs n'arrivent pas à gérer les accès mémoires efficacement. Dans certaines situations un peu compliquées impliquant des pointeurs ou des références, divers phénomènes complexes d'''aliasing'' des pointeurs peuvent générer des relectures intempestives de données en mémoire. Cela peut aussi arriver lorsqu'on compile du code qui provient de plusieurs librairies, ou quand du code lit régulièrement des constantes en mémoire ROM. La prédiction d'adresse constante ajoute un circuit qui analyse les dernières lectures, une fois qu'elles quittent le ROB, qu'elles ''commit''. Si, pour une instruction de lecture, l'adresse lue est toujours la même, alors l'instruction est ajoutée dans la table de prédiction d'adresse. Vu que les instructions qui accèdent toujours à la même adresse sont rares, il est préférable d'initialiser les compteurs à saturation de façon à ce qu'ils disent que toute nouvelle instruction change d'adresse. Il s'agit d'une méthode simple mais peu efficace, car cette situation est rare. Une autre méthode détecte les accès en enjambées, là encore quand les lectures quittent le ROB. Nous l’appellerons la '''prédiction d'adresse en enjambées'''. De tels accès surviennent régulièrement quand on accède des tableaux ou d'autres structures de données semblables, ce qui rend la technique très intéressante. Implémenter cette technique est facile : il suffit d'ajouter un circuit qui détecte les accès en enjambées, au lieu de celui qui détecte les accès constants. À chaque fois qu'une lecture entraine un succès de cache dans la table de prédiction d'adresse, l'enjambée est ajoutée à l'adresse contenue dans la table de prédiction d'adresse, l'ancienne adresse prédite est remplacée par celle avec l'enjambée d'ajoutée. Sur les Apple M2-M4, la prédiction d'adresse gére aussi bien les adresses constantes que les accès en enjambée. Le brevet qui décrit l'implémentation de l'unité de prédiction d'adresse et de valeur est potentiellement le suivant : [https://patents.google.com/patent/US11829763B2/en Early load execution via constant address and stride prediction]. En théorie, il est possible de faire mieux en repérant des accès mémoires qui se répètent de façon régulière et cyclique, même s'ils n'ont aucune enjambée. Ce genre d'accès se trouve assez souvent lorsque l'on manipule des listes chainées ou des structures de données assez irrégulières comme des arbres, des graphes, etc. Mais il n'y a pas encore de CPU qui implémente de telle optimisation. ===La prédiction de valeur : prédire la donnée lue=== Les techniques de '''prédiction de valeur''' (''Value Prediction'') consistent à prédire quelle est la donnée qui sera lue par une instruction de lecture. Oui, vous avez bien lu : le processeur est capable de parier sur la valeur qui sera chargée depuis la mémoire, de prédire si cette valeur vaut 0, 1, 1024, etc. Une fois son pari fait, il exécute les instructions du programme avec la valeur qu'il a pariée de façon spéculative. Si le pari est correct, le CPU continue l’exécution. Sinon, il est obligé de vider le pipeline et de recommencer avec la bonne valeur. Au premier abord, cette technique semble franchement mal partie tellement les chances de se tromper sont tellement énormes ! Mais le fait est que, dans certains cas, il est possible de spéculer correctement. Là encore, les techniques les plus simples fonctionnent dans deux cas. Le premier est celui où une donnée est relue à l'identique à chaque fois que la lecture est exécutée. Le second est celui où la donnée est incrémentée/décrémentée d'une constante qui est toujours la même d'une exécution de la lecture à l'autre. Une sorte d'enjambée constante, mais pour les données. Nous allons nous concentrer sur le premier cas, celui où une donnée est relue plusieurs fois sans être modifiée entre temps. L'idée de base est qu'une instruction de lecture qui s'exécute plusieurs fois de suite à la même adresse renvoie la même valeur à chaque fois. C'est parfaitement possible s’il n'y a eu aucune écriture à cette adresse entre-temps, d'où le fait qu'il faille surveiller si les prédictions constantes sont correctes. On peut se demander quelles sont les raisons qui font qu'une instruction de lecture renvoie la même valeur à chaque fois. Après tout, autant lire une seule fois la donnée et la garder dans un registre une bonne fois pour toutes ! Mais dans la réalité, on fait face à quelques limites, qui seront détaillées dans la liste suivante. La première est que le manque de registre fait que certaines données sont conservées en RAM et sont relues fréquemment. Cela arrive quand on stocke des constantes en mémoire. Par exemple, sur les processeurs x86, les constantes flottantes ne peuvent pas être intégrées dans nos instructions via le mode d'adressage immédiat. À la place, on les stocke en mémoire et on les charge dans les registres à chaque fois qu'on en a besoin. L'implémentation est similaire à la prédiction d'adresse, si ce n'est que c'est la donnée lue qui est prédite. Là encore, on a une '''table de prédiction de donnée lue''', consultée lorsqu'une lecture est émise. La table de prédiction de la donnée lue est une mémoire cache qui associe le ''Program Counter'' de la lecture à la donnée prédite, éventuellement couplé à des compteurs à saturation. Elle est consultée à chaque fois qu'une lecture est émise, la donnée adéquate est récupérée lors d'un succès de cache. On trouve aussi un circuit qui insère ou retire des instructions de la table de prédiction de donnée lue. Elle détecte les lectures prédictibles, couplé à une table d’apprentissage, et quelques circuits annexes liés au ROB ou aux files de lectures. Elle contient au minimum des compteurs à saturations, associés chacun à une instruction (son ''program counter'' pour être précis). ==L'optimisation du ''Mirroring memory operand''== Les processeurs modernes implémentent une optimisation concernant les accès mémoire. Cette optimisation a été décrite par Agner Fog dans ses manuels d'optimisation, sous le terme de '''''Mirroring memory operand'''''. Il a documenté son apparition sur les processeurs Zen 2 d'AMD, puis son évolution sur les processeurs Zen 4 et 5. L'optimisation est bizarrement absente de la microarchitecture Zen 3. Elle se manifeste dans une condition très précise : plusieurs instructions proches accèdent à la même adresse. Les deux instructions ne sont pas forcément consécutives, il peut y avoir une dizaine ou centaine d'instructions entre les deux. Une telle situation survient souvent sur les CPU CISC, et notamment sur les CPU x86, car ils ont peu de registres. les compilateurs font donc beaucoup d'échanges entre pile d'appel et registres, ce qui fait que des données écrites dans la pile sont souvent relue quelques instructions plus tard. Dans ce cas, la donnée lue/écrite est mémorisée dans un registre interne au processeur et peut être accédée très rapidement. Par exemple, imaginons plusieurs lectures proches à la même adresse, sans écriture entre elles. Dans ce cas, le processeur ne fera qu'une seule lecture en mémoire RAM, les lectures ultérieures liront la donnée depuis le registre interne. Comme autre exemple, imaginons une écriture suivie plus tard par une lecture à la même adresse. Dans ce cas, la donnée sera écrite dans un registre interne, en plus d'être propagée dans le cache ou la RAM, et la lecture ultérieure lira directement le registre interne. Le gain en performance est alors conséquent, comparé à du simple ''store-to-load forwarding''. Il faut noter que la technique marche aussi pour les opérations ''load-op'', pas seulement pour les instructions d'accès mémoire. Par exemple, prenons une écriture en RAM, suivies quelques instructions plus tard par une opération ''load-op'' à la même adresse. L'écriture écrit à une adresse, qui est lue par l'instruction ''load-op''. L'optimisation s'applique : l'instruction ''load-op'' lira l'opérande mémoire depuis un registre interne du processeur, qui contient une copie de la donnée écrite. Sur les processeurs Zen 2, l'optimisation marchait aussi pour les instructions PUSH et POP, qui lisaient/écrivaient dans la pile d'appel. Mais cette possibilité a été retirée sur les processeurs Zen 5. La technique détecte que deux instructions accèdent à la même adresse sous certaines conditions bien précises. L'optimisation ne marche que pour les modes d'adressages indicés et indirect, avec ou sans décalages, mais pas en mode absolu ou relatifs. Il faut préciser que l'optimisation ne marche que pour les registres généraux, pas les registres flottants. Les registres doivent être précisés de la même manière dans toutes les instructions. Par exemple, le processeur ne reconnait pas que l'addition des deux registres RCX + RAX est la même que RAX + RCX. Sur les CPU Zen 2 et 4, elle ne marchait que pour des opérandes de 32 et 64 bits, mais les processeurs Zen 5 gèrent des données de 8 et 16 bits aussi. Les décalages étaient limités à 8 bits sur le Zen 2, mais n'a plus de limites sur les Zen 4 et 5. ==La désambiguïsation mémoire a des effets de bord== La désambiguïsation mémoire a une caractéristique que l'exécution dans le désordre simple n'a pas : elle a des effets qui débordent sur la mémoire. Il est possible de savoir si un processeur fait de la désambiguïsation mémoire en regardant le bus mémoire. On s’aperçoit alors que les lectures se font dans un ordre différent de celui du programme. À l'opposé, l'exécution dans le désordre n'a pas d'effets observables de ce genre. Elle améliore la performance, elle modifie les durées observables des instructions, mais cela se limite à des questions de ''timings''. Le seul moyen de savoir si un processeur fait de l'exécution dans le désordre est de mesurer les latences/durées de chaque instruction. Mais aucun état observable au-delà des ''timings'' n'est modifié. Pas avec la désambiguïsation mémoire. ===La consistance mémoire=== Le fait que la désambiguïsation mémoire ait des effets observables ne pose aucun problème avec un seul processeur, ca elle est conçue pour. Par contre, les techniques de désambiguïsation mémoire peuvent poser des problèmes avec plusieurs processeurs. Il arrive que des programmes partagent une même portion de mémoire, et lisent/écrivent dedans. Avec plusieurs processeurs, chaque processeur effectue la désambiguïsation mémoire dans son coin sans se préoccuper des autres. Les opérations d'écriture peuvent être mises dans le désordre du point de vue des autres processeurs, et les lectures effectuées par les autres processeurs peuvent alors renvoyer de vielles données. Un exemple classique est celui de deux cœurs qui partagent le même cache ou la même mémoire, mais qui utilisent chacun une file d'écriture (''store buffer''). Imaginons que le premier cœur effectue une écriture, puis une lecture, à des adresses différentes. L'écriture est retardée grâce au tampon d'écriture, alors que la lecture lit directement le cache. Pour le premier processeur, l'écriture s'est réalisée avant la lecture, car pour lui, la fin de l'écriture signifie écrire dans le tampon d'écriture. Mais pour le second cœur, l'ordre s'est inversé : il voit la lecture dans le cache, puis l'écriture dans le cache. La raison est qu'il n'a pas connaissance du contenu du tampon d'écriture de l'autre cœur. Un autre exemple : les techniques de prédiction d'adresse et de valeur posent des problèmes avec les dépendances RAW. Elles permettent à une instruction mémoire de s'exécuter avant une autre, malgré la présence d'une dépendance RAW entre les deux. L'ordre entre les deux instructions change, alors que les autres processeurs ne devraient pas le voir. Et dans les faits, les processeurs qui incorporent ces techniques ont des modèles de consistance mémoire qui ne permettent pas ces optimisations en cas de dépendances RAW. Les deux techniques doivent idéalement être désactivées pour les instructions mémoire qui ont des dépendances RAW. Les programmeurs doivent tenir compte de ce genre de situations, dans des cas assez rares et liés à la programmation bas niveau. Pour cela, ils doivent savoir comment le processeur réorganise les accès mémoire. Concrètement, chaque processeur définit un '''modèle de consistance mémoire''' qui indique quelles optimisations sont activées et non. Il s'agit d'une sorte de contrat entre le logiciel et le matériel : les programmeurs savent comment le processeur va réorganiser les lectures et écritures et peuvent coder leurs programmes en fonction. L'implémentation du système de consistance mémoire dépend grandement de l'architecture, mais des modèles ont été standardisés afin de permettre aux programmeurs de savoir où placer les barrières mémoires. Il s'agit de vrais standards, formalisés, parfois mathématiquement, que des chercheurs étudient sur un plan mathématique et algorithmique. Le modèle préféré des programmeurs est la '''consistance séquentielle''', où les différents processeurs effectuent des accès mémoire dans l'ordre du programme et les accès mémoire simultanés sont interdits. La première contrainte interdit l'usage de mécanismes de désambiguïsation mémoire, la seconde interdit l'usage de caches non-bloquants. La consistance séquentielle a donc un cout en performance assez important, ce qui fait que les processeurs modernes n'utilisent pas la consistance séquentielle. Un modèle plus flexible est le '''''total store ordering''''', qui autorise la présence d'une file d'écriture et l'usage de caches non-bloquants pour les lectures (interdit d'avoir plusieurs écritures simultanées). C'est plus ou moins celui utilisé par les processeurs x86, comme on le verra plus bas. Il existe des modèles de consistance mémoire plus relâchés, appelés des '''modèles de consistance faible''', qui autorisent diverses optimisations de spéculation mémoire, la combinaison d'écriture (fusionner deux écritures à la même adresse en une seule), etc. Ils sont supportés sur les architectures ARM et POWER. ===Les barrières mémoire=== Le changement d'ordre des lectures et écritures peut poser occasionnellement des problèmes avec la programmation pour les architectures multi-processeur ou multicœurs. Il existe des morceaux de code qui ne marchent que si la consistance séquentielle est respectée et il faut faire en sorte qu'ils marchent sur des processeurs avec un modèle de consistance mémoire différent. Pour cela, les processeurs incorporent des instructions appelées '''barrières mémoires''' ou '''''Fences'''''. Elles forcent le processeur à terminer tous les accès mémoire qui précédent dans l'ordre du programme. Certains processeurs possèdent une barrière mémoire pour les lectures et une autre pour les écritures. D'autres processeurs ajoutent des barrières mémoires dédiées à des cas particuliers. Elles permettent de forcer les accès à des données partagées à dérouler correctement. [[File:Fences.png|centre|vignette|upright=2|Fences.]] Leur implémentation est assez simple : elles empêchent l'émission de toute nouvelle instruction mémoire, tant qu'une condition précise n'est pas réunie. Par exemple, la barrière mémoire pour les écritures attend que la file d'écriture soit vidée avant de démarrer le moindre accès mémoire. Un compilateur peut placer les barrières mémoires au bon endroit, avec un peu d'aide du programmeur. Certains langages de programmation permettent d'indiquer au compilateur qu'une donnée doit toujours être lue depuis la mémoire RAM, via le mot-clé volatile. C'est très utile pour préciser que cette donnée est potentiellement partagée par plusieurs processeurs ou manipulable par des périphériques. Les compilateurs peuvent placer des barrières mémoires lors des lectures ou écritures sur ces variables, mais ce n'est pas une obligation. Il arrive aussi que le programmeur doive manipuler explicitement des barrières mémoires. Utiliser l'assembleur est alors une possibilité, mais qui est rarement exploitée, pour des raisons de portabilité. Pour limiter la casse, certains systèmes d'exploitations ou compilateurs peuvent aussi fournir des barrières mémoires explicites, encapsulées dans des bibliothèques ou cachées dans certaines fonctions. ===La modèle de consistance des processeurs x86=== Après avoir vu la théorie, passons maintenant à la pratique. Dans cette partie, on va voir les modèles de consistances utilisés sur les processeurs x86, ceux qu'on retrouve dans les PC actuels. Le modèle de consistance des processeurs x86 a varié au cours de l'existence de l'architecture : un vulgaire 486DX n'a pas le même modèle de consistance qu'un Core 2 duo, par exemple. Ils ont évolués au cours du temps, avec l'arrivée de nouvelles optimisations. De plus, leur formalisation a été progressive, et s'est faite au fur et à mesure. En général, les modèles de consistance des processeurs x86 ont toujours étés assez restrictifs, si on les compare aux autres processeurs. Il s'agit de modèles de type ''total store ordering'' (TSO), au moins dans les grandes lignes. Ils permettent donc d'effectuer certaines lectures en avance, soit parce que les lectures se font à des adresses différentes, soit par utilisation de réacheminement écriture vers lecture. Les possibilités d'exécution en avance des lectures varient suivant le processeur, elles deviennent de plus en plus performantes avec le temps. Le premier modèle de consistance est apparu sur les premiers processeurs x86 et est resté en place sur tous les processeurs de marque Pentium. Les processeurs de l'époque n'incorporaient pas beaucoup d'optimisations, le budget en transistor n'était pas assez conséquent pour ça, la mémoire n'était pas assez lente. Le modèle TSO de l'époque n'autorisaient que peu de lectures anticipées. Une lecture ne peut être exécutée en avance que sous les conditions suivantes : * les écritures contournées se font dans la mémoire cache ; * la lecture doit se faire dans la mémoire RAM ; * les écritures se font à une adresse différente de la lecture ; * aucune transaction avec un périphérique ne doit être en cours. A partir du Pentium 4, les choses changent. Le Pentium 4 est en effet le premier processeur à implémenter des techniques permettant d’exécuter plusieurs processus en parallèle, dont l'Hyperthreading. En conséquence, le modèle de consistance a dû être assoupli pour éviter de perdre bêtement en performance. Il s'agit d'un vrai modèle de type TSO, la majeure partie des contraintes sur les lectures anticipées sont réduites au minimum. Il y a aussi des contraintes supplémentaires pour les instructions dites atomiques, ainsi que pour les instructions qui accèdent aux périphériques ou aux entrées-sorties. Une optimisation séparée du TSO est que les écritures en mémoire peuvent changer d'ordre dans un cas exceptionnel : les écritures effectuées par les instructions de copie mémoire comme REPMOVSD, REPSCACB, ainsi que les instructions SSE MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, MOVNTPD. Les écritures effectuées dans ces instructions peuvent se faire dans un désordre complet (ou presque). Lors du passage au 64 bits, le modèle mémoire des processeurs x86 de l'époque a été formalisé dans un papier d'Intel appelé [https://www.cs.cmu.edu/~410-f10/doc/Intel_Reordering_318147.pdf Intel® 64 Architecture Memory Ordering White Paper], mis à jour en 2008 suite à quelques problèmes techniques. La formalisation du modèle a été d'une grande aide aux programmeurs, qui devaient se débrouiller avec des documentations qui ne formalisaient pas de modèle de consistance précis. Le document décrit un modèle de consistance mémoire portant le nom barbare de ''total lock order + causal consistency” (TLO+CC)'', qui admet plus d'optimisations que le modèle TSO. Mais la description était en réalité légèrement différente du modèle mémoire réellement implémenté sur les processeurs existants, qui utilisaient du TSO, sauf en de rares occasions. Les concepteurs de processeur font souvent cela, à savoir définir un modèle de consistance plus laxiste que réellement implémenté, pour des questions de comptabilité. Ainsi, si les futurs processeurs de la marque implémentent un modèle plus laxiste, les programmes déjà codés continuent de fonctionner. Le modèle décrit dans la documentation laisse de la marge pour le futur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le parallélisme mémoire | nextText=Le parallélisme mémoire }} </noinclude> nhb9typkahyn7yp3m31a4ucgj3b1t9q 774179 774178 2026-10-04T19:53:25Z Mewtow 31375 /* Les schedulers mémoire unifiés */ 774179 wikitext text/x-wiki Dans les chapitres précédents, nous avons vu la mal-nommée exécution dans le désordre dans les chapitres précédents, qui devrait plutôt s'appeler l'émission dans le désordre. Elle modifie l'ordre des instructions pour gagner en efficacité, mais cela n'est valable que pour les instructions travaillant sur des registres ! Nous n'avons pas parlé de l'exécution dans le désordre des accès mémoire, car ils sont un peu à part. Et pour comprendre pourquoi, nous allons dédier ce chapitre à la gestion des accès mémoire dans le pipeline. Avant de poursuivre, faisons un rappel rapide sur les dépendances de données. Il faut distinguer deux types de dépendances de données : les dépendances de registres (deux instructions manipulent le même registre), et les dépendances d'adresse (deux instructions manipulent la même adresse). Deux instructions ont une dépendance d'adresse si elles écrivent ou lisent la même adresse mémoire. Des instructions mémoires qui lisent/écrivent des adresses différentes sont indépendantes. Lorsque deux instructions mémoire lisent/écrivent à la même adresse, on parle d''''aliasing mémoire'''. Formellement, l'aliasing mémoire n'est ni plus moins que l'application des dépendances de données aux instructions mémoire. Et comme les autres dépendances de données, il existe plusieurs types d'aliasing mémoire : RAR, RAW, WAR et WAW. Les dépendances RAR ne posent aucun problème, les lectures peuvent changer d'ordre sans aucun problème, tant qu'il n'y a pas d'écritures entre les deux. Par contre, les autres dépendances imposent trois contraintes. * Les dépendances RAW imposent que les lectures ne doivent pas s'exécuter avant une écriture à la même adresse. * Les dépendances WAR imposent que l'on ne peut pas déplacer une écriture avant une lecture à la même adresse. * Enfin, les dépendances WAW font que l'ordre des écritures doit être celui du programme : impossible de changer l'ordre de deux écritures. Les trois contraintes peuvent s'implémenter de plusieurs manières différentes. Comme pour les dépendances de données normales, seules les dépendances RAW sont de vraies dépendances de données. Les deux autres dépendances viennent du fait que l'on utilise la même adresse pour stocker des valeurs différentes. Détecter les cas d'''aliasing'' mémoire est primordial pour gérer la désambiguïsation mémoire, et cela demande de comparer des adresses. S'il existe des techniques similaires au renommage de registre permettent de les faire disparaitre, elles sont très limitées. Diverses optimisations permettent d'émettre des accès mémoire dans le désordre, exactement comme l’exécution dans le désordre le fait pour les autres instructions. Elles utilisent pour cela des ''schedulers'' spécialisées dans les accès mémoire. Il faut vraiment insister sur le fait que l'exécution dans le désordre proprement dite ne se préoccupe pas des micro-opérations mémoire. Pour faire la distinction, on parle de '''désambiguïsation mémoire''' (''memory disambiguation'') pour parler de l'exécution dans le désordre des accès mémoire. Le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. La raison est que la donnée lue est utilisée par d'autres instructions, dépendantes de la lecture. Elles sont donc à la tête d'une chaine de dépendances de données qui peut bloquer le pipeline si elle n'est pas résolue très tôt. Dès qu'une lecture peut être lancée, elle accède directement au cache de données. Par contre, si l'''aliasing'' ne le permet pas (écriture à la même adresse pas encore effectuée) ou que l'adresse à lire n'a pas encore été calculée, la lecture attend son tour. Le problème est que la désambiguïsation mémoire demande de comparer des adresses, pour détecter les dépendances d'adresse. Et cela pose de sérieuses contraintes pour son implémentation. L'exécution dans le désordre normale compare des registres, ce qui est beaucoup plus simple. Les registres utilisés sont directement encodés dans l'instruction elle-même, ce qui fait qu'on peut détecter les dépendances de registre après le décodage. Et elles peuvent même être éliminées par le renommage de registre pour les dépendances WAW et WAR. Par contre, les adresses ne sont pas encodées dans l'instruction elle-même (sauf avec le cas particulier de l'adressage absolu), mais sont lues depuis les registres ou calculées par une unité de calcul. Les adresses ne sont donc connues qu'une fois que l'instruction a attendu suffisamment de temps dans le ''scheduler''. ==L’émission dans l'ordre des accès mémoire== Avant toute chose, faisons quelques rappels sur l'exécution dans le désordre "normale", sans désambiguïsation mémoire. Les micro-opérations renommées sont mises en attente dans un ''scheduler'', le temps que leurs opérandes soient disponibles. Les µops mémoire font cependant exception. ===La file de µops mémoire=== Avec l''''émission dans l'ordre des accès mémoire''', le processeur émet les accès mémoire dans l'ordre du programme. Cela implique que l'unité mémoire n'a pas de ''scheduler'', ni de station de réservation associée. À la place, les micro-opérations mémoire sont placées dans une file de micro-opération dédiée, appelée la '''file de µops mémoire'''. La file de µops mémoire est une mémoire FIFO, dont chaque entrée peut accueillir une µops mémoire. [[File:Unité de gestion des accès mémoires dans l’ordre.png|centre|vignette|upright=2.0|Unité de gestion des accès mémoires dans l’ordre]] Par contre, le processeur utilise des ''schedulers'' pour les unités de calcul. Autant les accès mémoire se font dans l'ordre, autant les instructions arithmétiques/logiques/branchements/autres sont exécutées dans le désordre. En clair, le processeur supporte l'exécution dans le désordre, sauf pour les micro-opérations mémoire. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] Vu de l'extérieur, il faut attendre qu'un accès mémoire soit terminé pour lancer le suivant. Avant d'émettre une micro-opération mémoire, la file de micro-opération (son ''scoreboard'') vérifie si l'unité d'accès mémoire est libre ou non. Si elle est occupée, un accès mémoire est en cours, et on ne peut pas émettre de micro-opération mémoire. Une fois l'accès mémoire terminé, la mémoire ou le cache envoient un signal pour indiquer que l'accès est fini, et c'est au tour d'une nouvelle micro-opération mémoire. L'unité mémoire a un port sur lequel on envoie la micro-opération émise, que ce soit une écriture ou une lecture. Une file de µops implique la ''lecture après émission'', à savoir que les µops mémoire quittent la file de µops, avant de lire les registres adéquats. Il y a donc une connexion entre banc de registre et unité mémoire, comme sur n'importe quel autre processeur. Mais la file de µops n'est pas connectée au banc de registre, ni au réseau de contournement. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] L'organisation précédente laisse penser que les calculs d'adresse sont forcément réalisés dans l'unité mémoire. Mais ce n'est pas une obligation. Il est parfaitement possible de scinder une µops mémoire en deux : une µop mémoire proprement dite et une µop de calcul d'adresse. La liaison entre les deux doit cependant se faire en utilisant un registre. L'adresse calculée est enregistrée dans un registre d'adresse "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. ===La file d'écriture : une FIFO de mise en attente des écritures=== L'unité mémoire exécute une µops mémoire à la fois. Avec un détail particulier : les écritures doivent attendre que toutes les instructions se terminent, avant de pouvoir s'exécuter. Sans ça, les exceptions précises sont impossibles, la prédiction de branchement l'est aussi et l'exécution dans le désordre aurait des conséquences fâcheuses (en exécutant des écritures en avance). Précisément, les écritures doivent respecter deux contraintes : s'exécuter dans le tout dernier étage du pipeline, s'exécuter dans l'ordre du programme. Il serait possible de bloquer l'émission des écritures tant que le ROB ne donne pas son accord. Mais ce n'est pas du tout ce qui est fait. A la place, les écritures sont mises en attente dans une mémoire FIFO, appelée la '''file d'écriture''', ou encore la ''Write Queue''. Les écritures sont mises en attente, jusqu'à ce que le ROB leur donne le okay pour transfer l'écriture en mémoire RAM. Nous en avions déjà parlé dans le chapitre sur le pipeline, dans la section sur les exceptions précises. Mais quelques rappels ne feront pas de mal. L'écriture est insérée dans la file d'écriture lorsqu'elle est émise, elle en sort à l'étage de ''Writeback'' si tout se passe bien. Si une exception est détectée, l'étage de ''Writeback'' vide la file d'écriture, ce qui annule les écritures mises en attente, faites à tort. De plus, la file d'écriture est une mémoire FIFO, qui mémorise les écritures dans l'ordre du programme. En effet, les écritures sont ajoutées dans la file d'écriture lorsqu'elles sont émises, et elles sont émises dans l'ordre du programme. [[File:Store Queue sur un pipeline fixe.png|centre|vignette|upright=2|Write Queue sur un pipeline fixe]] La file d'écriture garantit que les écritures sont dans l'ordre du programme. Et cette propriété élimine totalement les dépendances WAW. Le fait que les écritures se font à la fin du pipeline élimine quant à elle les dépendances WAR. Il ne reste plus qu'à gérer les dépendances RAW, celles où une lecture lit une donnée écrite par une autre instruction. Une telle situation survient, car les écritures sont mises en attente pendant un certain temps, la donnée écrite peut être lue pendant ce laps de temps. La lecture ne peut pas lire la donnée dans le cache, car la donnée à lire n'y est pas encore enregistrée. Le cas est rare, car il demande de lire une donnée qu'on vient d'écrire, mais il existe. Il se manifeste surtout sur les processeurs avec peu de registre, où des données doivent régulièrement être déplacées temporairement en mémoire RAM. Pour éviter cela, une solution consiste mettre en attente les lectures si une écriture est en attente. Les lectures sont maintenues dans la file de µops tant que la file d'écriture n'est pas vide. La file de µops mémoire est suivie d'un circuit de détection des dépendances, un '''''scoreboard'' mémoire''' qui est assez simple. L'unité mémoire fournit un bit qui indique si la file d'écriture est vide ou non, ainsi qu'un second bit qui indique qu'elle est vide. Les deux ne sont pas compliqués à générer, ils font partie intégrante de l'interface de toute mémoire FIFO digne de ce nom. Le ''scoreboard'' mémoire utilise ces deux bits pour savoir s'il peut émettre une micro-opération mémoire. Il n'émet pas de lecture tant que la file d'écriture n'est pas vide. Il peut par contre émettre des écritures tant qu'elle n'est pas pleine. Une meilleure solution serait d'interdire les lectures, mais seulement en cas de dépendance mémoire détectée. Et c'est là qu'intervient la technique du '''''load bypassing'''''. Elle autorise une lecture, si elle n'a aucune dépendance avec les écritures mises en attente dans la file d'écriture. Elle doit être mise en attente dans le cas contraire. Pour l'implémenter, il faut comparer l'adresse à lire avec toutes les adresses dans la file d'écriture. La lecture est exécutée immédiatement si aucune correspondance n'est trouvée, mais elle est mise en attente dans le cas contraire. Pour cela, la file d'écriture est modifiée et devient un mélange entre FIFO et mémoire associative. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'' : la lecture doit être bloquée. Sinon, c'est un défaut de ''Write Queue'', la lecture accède au cache, là où se trouve la donnée à lire. Dans le chapitre sur le contournement, nous avons parlé d'une forme de contournement qui corrige ce problème. Les lectures peuvent lire la donnée depuis la file d'écriture si besoin, ce qui renvoie la donnée adéquate. Pour cela, les lectures consultent à la fois le cache et la file d'écriture, comme dit plus haut. L'adresse à lire est envoyée à la file d'écriture, et celle-ci vérifie si une écriture est en attente à cette adresse. Si c'est le cas, c'est un succès de ''Write Queue'', et celle-ci envoie alors la donnée associée à l'étage d'accès mémoire. Sinon, la lecture accède au cache, là où se trouve la donnée à lire. La solution porte le nom de ''Store to load Forwarding'', ou encore de '''réacheminement écriture-vers-lecture'''. [[File:Store to load forwarding sur un pipeline simple.png|centre|vignette|upright=2|Store to load forwarding sur un pipeline simple]] La lecture est mise en attente dans l'unité mémoire, vu que c'est elle qui fait les comparaisons d'adresse. Du point de vue extérieur, le ''scoreboard'' mémoire ne voit pas s’il y a eu succès dans la file d'écriture ou non. Il voit juste que l'unité mémoire est occupée et ne peut pas accepter de nouvelle lecture. Elle reste occupée durant quelques cycles, en cas de défaut de ''Write Queue'', le temps de faire la lecture dans le cache. Par contre, en cas de succès de ''Write Queue'', elle reste occupée tant que la lecture n'a pas donné de résultat, ce qui inclue le temps de mise en attente. Les processeurs haute performance implémentent tous le ''Store to load Forwarding'', mais quelques processeurs basse performance ne le font pas. Ils se contentent du ''load bypassing'', qui offre des performances correctes pour un cout en matériel bien plus modeste. Pour donner un exemple, la micro-architecture Jaguar d'AMD était dans ce cas. Elle a été utilisée sur les consoles de jeu de 8ème génération, comme la Xbox One et la Playstation 4. Les processeurs haute performance préférent utiliser plus de circuits pour implémenter le ''Store to load Forwarding'', mais l'implémentation n'est souvent pas complète. En effet, l'implémentation est souvent simple et ne prend en compte que des lectures/écritures à des adresses identiques, sans compter que la taille des données doit aussi être identique. Une écriture d'un entier de 32 bit sera contournée et envoyée à une lecture de 32 bits à la même adresse. Mais la lecture lit seulement 16 bits sur les 32 écrits, le contournement peut ne pas marcher. Concrètement, ce cas précis fonctionnera. Mais le cas inverse, avec une lecture lisant plusieurs écritures de taille plus petite, ne marchera pas sur tous les processeurs. Ne parlons pas du cas où les données écrites sont à cheval sur deux lignes de cache. ==L'exécution anticipée des calculs d'adresse== L'exécution dans le désordre fonctionne mieux si les lectures sont exécutées le plus tôt possible. Les lectures lisent des données qui sont utilisées par d'autres instructions. Si la lecture tarde, les autres instructions sont mises en attente. Exécuter les lectures le plus tôt possible est donc crucial. Mais comment faire ? Un moyen pour ça est de faire en sorte que les adresses à lire/écrire soient connues le plus tôt possible. Mais cela implique que les calculs d'adresse ne sont pas fait en même temps que l'accès mémoire. Pour cela, il est possible de séparer les µops mémoire en deux micro-instructions : une pour le calcul d'adresse et l'autre pour les accès mémoire. On parle de '''calcul anticipé des adresses'''. Avant de poursuivre, un minimum de terminologie. Nous parlerons d'AGU pour désigner les unités de calcul d'adresse, ce qui est une abréviation de ''Adresse Generation Unit''. Nous parlerons aussi de '''µops AGU''' pour parler des µops de calcul d'adresse. Le terme est de moi, il est assez parlant : "µops exécutés dans les AGUs". Nous utiliserons ce terme pour les séparer des µops entières classiques, comme les additions/soustractions/multiplications arithmétiques, les décalages/rotations, etc. Le calcul anticipé des adresses est utilisé avec ou sans désambiguïsation mémoire. Elle a des avantages avec et sans. Sans, elle calcule les adresses en avance, ce qui fait que des lectures/écritures mises en attente ont leurs opérandes prêtes plus rapidement. Avec, cela permet de connaitre les adresses en avance, ce qui permet de détecter plus rapidement les dépendances d'adresse. Mais laissons cela pour plus tard. ===La ''Load/Store Queue'' : une file de µops améliorée=== Implémenter cette technique demande de faire deux choses. Premièrement, il faut faire les calculs d'adresse en-dehors de l'unité mémoire, soit dans une ALU entière, soit dans une AGU dédiée. Deuxièmement, les adresses calculées doivent être mises en attente quelque part, et être liées facilement à la µop mémoire associée. Pour le second point, la file de µops mémoire parait être un bon candidat, mais n'est pas exactement la solution. Une file de µops au sens strict ne mémorise pas les opérandes des µops, donc pas d'adresse. Cela implique la lecture après émission, ce qui veut dire que l'adresse à lire/écrire est lue depuis les registres une fois que la µops sort de la file de µops (idem pour la donnée à écrire). En pratique, cette implémentation rend difficile de faire les calculs d'adresse ailleurs que dans l'unité mémoire. Heureusement, il est possible d'adapter la file de µops pour gérer la lecture avant émission. L'idée est qu'elle mémorise, pour chaque µop mémoire, l'adresse à lire/écrire et d'autres informations. Il ne s'agit donc plus vraiment d'une file de µops mémoire mais quelque chose d'autre, une sorte d'intermédiaire entre une station de réservation et une file de µops. D'un côté, elle fonctionne comme une file de µops car elle émet les µops dans l'ordre du programme. De l'autre, elle mémorise les opérandes des µops mémoire, ce qui en fait une pseudo-station de réservation. Une telle structure est appelée une '''''Load/Store Queue''''' (LSQ). Une LSQ contient plusieurs entrées, chaque entrée pouvant accueillir une µops mémoire et ses opérandes. Une entrée contient plusieurs champs : un qui précise si l'opération est une lecture ou une écriture, un pour l'adresse mémoire à lire/écrire, un autre pour la donnée à écrire (qui est vide pour une lecture), un autre pour le registre de destination des lectures. Les deux derniers champs sont souvent fusionnés en un seul, qui est interprété différemment selon que l'instruction est une lecture ou une écriture. Elle contient aussi des bits de validité, qui indique si l'adresse ou la donnée sont disponibles, ou si le champ associé est vide. [[File:Load Store Queue unifiée.png|centre|vignette|upright=2|Load Store Queue unifiée]] La LSQ doit faire le lien entre une adresse calculée par l'AGU, et l'entrée associée de la LSQ. Pour cela, l'unité d'émission associe un '''identifiant LSQ''' à une µops mémoire. L'identifiant est généré à l'émission. Aussi bien la µops de calcul d'adresse que a µop mémoire ont toutes deux le même identifiant. L'identifiant est propagé avec la µop de calcul d'adresse, comme tout signal de commande. En sortant de l'AGU, l’identifiant est transmis à la LSQ en même temps que l'adresse. La LSQ regarde quelle entrée a cet identifiant, et mémorise l'adresse dedans. La gestion des données à écrire se fait elle avec le système de contournement normal. La LSQ sait dans quel registre se trouve la donnée à écrire, elle surveille juste le système de contournement pour détecter quand la donnée est disponible. L'implémentation impose que toutes les instructions mémoire sont découpées en deux µops : une µop pour calculer l'adresse, et une µop mémoire. C'est un défaut assez important, car une instruction mémoire prendra deux places dans le ROB, dans les stations de réservation, etc. Vu que les instructions mémoire sont très fréquentes, la capacité du ROB/autres est alors fortement réduite. Mais diverses optimisations de macro-fusion permettent de réduire les problèmes. Mais nous laisson cela au chapitre sur les processeurs superscalaires. ===Le contournement des adresses mémoire avec une LSQ=== Avec une LSQ, les registres sont lus quand la µop mémoire est insérée dans la LSQ, si elles sont disponibles. Si elles ne sont pas encore disponibles, elles seront envoyées par le système de contournement plus tard. Cela implique qu'il y ait un système de contournement, qui relie les ALUs entières à la LSQ. Les ALUs envoient leur résultat à la LSQ, qui place alors l'adresse calculée dans la bonne entrée, la bonne "µop". [[File:Load-Store Queue.png|centre|vignette|upright=2|Load-Store Queue]] Une autre solution utilise des AGU spécialisées. L'avantage est que cela simplifie grandement le réseau de contournement : seules les AGU sont connectées à la LSQ. Un autre avantage est lié à l'adressage de la destination. En utilisant des ALU entières, il faut préciser que le résultat est à destination de la LSQ, pour configurer le réseau de contournement. Avec des AGU,, l'adresse calculée est envoyée automatiquement à la LSQ, pas besoin de préciser la destination. [[File:ALU et LSQ.png|centre|vignette|upright=2|AGUs et Load-Store Queue.]] Dans les deux cas, les µops AGU sont alors considérées comme des opérations entières un peu spéciales, gérées par le système d'exécution dans le désordre. Elles peuvent être émises dans le désordre, mais la LSQ se chargera de faire les accès mémoire dans l'ordre. Il y a donc calcul des adresses dans le désordre, mais accès mémoire dans l'ordre. ==Les ''schedulers'' mémoire unifiés== Avec la LSQ précédente, les µops mémoire s'exécutent dans l'ordre. Il arrive que des lectures prêtes soient mises en attente, parce qu'une lecture précédente attend ses opérandes. Et ce retard sur les lectures se répercute sur les instructions lecture-dépendantes, donc sur le reste du pipeline. Mais avec l'implémentation qui va suivre, il est possible exécuter les lectures dans le désordre tant que les conditions d'''aliasing'' sont respectées. L'implémentation améliore la LSQ pour en faire un ''scheduler'' spécialisé dans les µops mémoire. Il sera appelé le '''''scheduler'' mémoire''' dans ce qui suit, mais il est souvent appelé la LSQ dans certains documents. [[File:Processeur avec émission dans le désordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans le désordre des accès mémoire]] Le ''scheduler'' mémoire mémorise, pour chaque µop mémoire, l'adresse à lire/écrire. Ca en fait donc une station de réservation, si on suit la terminologie utilisée dans ce cours, vu qu'elle mémorise les opérandes des µops. Et cela implique que ces adresses ont été calculées ailleurs, dans une ALU entière, ou dans une AGU séparée de l'unité mémoire. Pour les µops dont l'adresse est inconnue, le cas dépend de l'implémentation. La version la plus conservatrice suppose qu'elle a des dépendances possibles avec toutes les autres. [[File:Contournement pour la file de µops mémoire.png|centre|vignette|upright=2|''scheduler'' mémoire]] [[File:File de µops mémoire avec calcul d'adresse via AGU.png|centre|vignette|upright=2|ALU et ''scheduler'' mémoire]] Lorsqu'une micro-opération mémoire est émise, elle est insérée dans le ''scheduler'' mémoire. Si l'adresse à lire/écrire est disponible, elle est immédiatement lue depuis les registres, idem pour une éventuelle donnée à écrire. Sinon, la micro-opération attend dans le ''scheduler'' mémoire que son adresse soit calculée par le processeur. ===Le système de détection des dépendances mémoire=== Le ''scheduler'' mémoire intègre un système de détection des dépendances, qui est bien différent de celui d'une station de réservation normale. Les ''schedulers'' mémoire comparent non pas des noms de registres, mais des adresses/opérandes. Le ''scheduler'' mémoire détecte des dépendances d'adresse entre µops, en comparant les adresses à lire/écrire de toutes les µops en attente. Chaque micro-opération en attente compare son adresse avec celle de toutes les micro-opérations précédentes. Ou presque, la comparaison ne doit tenir compte que des écritures, et de celles situées avant dans l'ordre du programme. Le ''scheduler'' mémoire doit donc mémoriser les instructions dans l'ordre du programme, tout en faisant des comparaisons d'adresse. Ça en fait une sorte hybride entre mémoire associative et mémoire FIFO. Nous avions vu dans le chapitre "L'exécution dans le désordre" qu'il y a plusieurs manières pour implémenter une station de réservation : avec une mémoire associative, avec préplanification, avec des matrices de dépendances. Le problème est qu'on ne peut pas utiliser une mémoire associative pour le ''scheduler'' mémoire, qui utilise des comparateurs pour comparer les noms de registres/adresses. En effet, la détection des dépendances mémoire demande de faire beaucoup de comparaisons : chaque adresse est comparé à toutes les autres. Et les adresses faisant facilement 32 à 64 bits, les comparateurs sont bien plus imposants que ceux qui comparent des noms de registres de 5-10 bits. Le cout en matériel serait trop élevé, de même que le temps de calcul des dépendances. À la place, on utilise des matrices de dépendances. La détection des dépendances mémoire utilise précisément deux matrices de ce type : une matrice de disponibilité, et une matrice de dépendance. Ces matrices forment une espèce de tableau carré, organisé en lignes et en colonnes. L'instruction dans la énième entrée se voit attribuer la énième ligne et la énième colonne. À l'intersection d'une ligne et d'une colonne, on trouve un bit qui indique si l'instruction de la ligne et celle de la colonne ont une dépendance. La '''matrice de disponibilité''' permet de bloquer l'exécution des lectures/écritures tant qu'une instruction précédente n'a pas calculé son adresse, tant qu'on ne peut pas vérifier les dépendances. Lorsqu'une instruction est ajoutée dans le ''scheduler'' mémoire, elle met tous les bits de la colonne attribuée à 1 si l'adresse à lire/écrire est inconnue, puis les met à 0 une fois l'adresse calculée. Une instruction ne peut pas démarrer si, sur la ligne qui lui est attribuée, se trouve un 1. Plus précisément, si on trouve un 1 pour les instructions plus anciennes, ce qui fait qu'une partie de la matrice est ignorée. Les bits notés X dans le tableau suivant sont ignorés, car ils précisent la disponibilité des opérandes d'un opérande ultérieure, ce qui est inutile. En clair, les calculs d'adresse modifient les colonnes, et on vérifie les lignes pour autoriser l'exécution. {|class="wikitable" |- ! Instructions mémoire dans la LSB, triées de la plus ancienne à la plus jeune ! 0 !! 1 !! 2 !! 3 !! 4 !! 5 !! 6 |- ! 0 | 1 || X || X || X || X || X || X |- ! 1 | 0 || 0 || X || X || X || X || X |- ! 2 | 0 || 1 || 0 || X || X || X || X |- ! 3 | 0 || 1 || 1 || 1 || X || X || X |- ! 4 | 0 || 0 || 0 || 0 || 1 || X || X |- ! 5 | 0 || 0 || 1 || 0 || 0 || 1 || X |- ! 6 | 1 || 1 || 1 || 1 || 1 || 1 || 0 |} Les '''matrices de dépendances''' fonctionnent sur le même principe, mais déterminent les dépendances mémoire. Lorsque le processeur a calculé une adresse, il la compare avec les adresses à lire/écrire déjà dans le ''scheduler'' mémoire. Il met alors à jour les bits de la colonne de la matrice de dépendance : le bit à l'intersection d'une ligne et d'une colonne est mis à 0 si les deux instructions n'ont pas de dépendances, à 1 si elles en ont une. Une instruction mémoire est exécutée si tous les bits de sa ligne sont à 0. Là encore, on ne tient compte que des bits pour les instructions plus anciennes, la matrice a encore une fois une forme triangulaire, comme pour la matrice de disponibilité. ===Les optimisations de la LSQ=== Une optimisation scinde le ''scheduler'' mémoire en deux structures, aux rôles légèrement différents. La première gère la disponibilité des opérandes, la seconde gère seulement les dépendances mémoire. La première est une station de réservation où les instructions sont mises en attente tant que leurs opérandes ne sont pas prêts. La seconde met en attente des micro-opérations dont les opérandes sont prêts, mais qui doivent attendre pour une autre raison : dépendance mémoire, cache occupé, etc. L'avantage est qu'on remplace une grosse station de réservation par deux plus petites, mais l'intérêt est surtout relié au système de détection des dépendances. Sans cette optimisation, on a deux matrices énormes reliées à une ''Load/Store Queue'' unique. Avec la séparation, la matrice de disponibilité est reliée uniquement à la première station de réservation, alors que la matrice de dépendance est reliée uniquement à l'autre. Les deux matrices sont donc plus petites, car reliées à deux stations de réservation mémoire plus petites. ==L'exécution spéculative des accès mémoires== Pour rappel, le but de la désambiguïsation mémoire est d'exécuter les lectures le plus tôt possible. Les données lues sont en effet, le départ d'une série d'instructions dépendantes, généralement des instructions arithmétiques, mais parfois d'autres lectures. Quand on manipule des structures de données, il n'est pas rare qu'une lecture lise un pointeur-adresse utilisé par une autre lecture. Les techniques de désambiguïsation mémoire que nous avons vu précédemment sont dites conservatrices, à savoir qu'elles ne changent pas l'ordre de deux instructions mémoire si elles ont une dépendance d'adresse. Elles exécutent une lecture le plus tôt possible, mais seulement si on sait que celle-ci n'aliase pas d'écritures précédentes. Et cela demande de connaitre les adresses des écritures précédentes dans l'ordre du programme. Si une lecture est précédée par une ou plusieurs écritures, les adresses à écrire doivent être connues. Il se peut en effet qu’une écriture dont on ne connait pas l'adresse aliase la lecture. La garantie est liée à la présence de la file de µops mémoire ou la file de µops STORE. Si une seule écriture a une adresse inconnue, elle reste dans la file de µops mémoire et bloque l'émission de toutes les autres micro-opérations mémoire, lecture comme écriture. Mais il existe des techniques dites spéculatives, qui modifient l'ordre des accès mémoire sans se soucier de leurs dépendances. Elles émettent les lectures dès que leurs opérandes sont prêts, sans se préoccuper de l'aliasing avec une écriture précédente. Pour éviter tout problème, le processeur vérifie si aucune dépendance mémoire n'est violée et corrige le tir dès qu'une violation est détectée. Le processeur vérifie s'il a fait une erreur de prédiction, en vérifiant les adresses à écrire ou lire. En cas d'erreur, le processeur fait comme lors d'une prédiction de branchement ratée : il remet le pipeline en ordre et ré-exécute les accès mémoire dans le bon ordre. Il s'agit d'un cas particulier d’'''exécution spéculative''', au même titre que la prédiction de branchement. Toutes les techniques de ce genre exécutent des lectures dans le désordre. L'avantage est que cela permet d'exécuter les lectures en avance. Vu que la donnée lue est l'opérande d'autres instructions, les exécuter en avance fait prendre de l'avance à un paquet d'instructions. Mais il faut comparer cet avantage au risque de mauvaise prédiction. Et aussi bizarre que cela puisse paraître, cela donne de bons résultats. Il faut dire que les situations où une lecture relit une donnée tout juste écrite sont rares : même pas 2 à 3 % des lectures. Les dépendances mémoire sont donc très rares, les cas d'''aliasing'' sont vraiment limités. En termes de cout en transistors, le hardware qui vérifie les dépendances mémoire reste globalement le même à peu de choses près, il est juste déplacé dans le pipeline et adapté à sa nouvelle fonction. ===La file de lectures=== La technique que nous allons voir émet les lectures dans l'ordre, ce qui simplifie énormément la gestion des dépendances. Les lectures sont émises dans l'ordre, les écritures aussi. Par contre, l'ordre entre lectures et écritures n'est pas respecté. Une lecture peut passer avant une écriture sans problèmes, voire avant plusieurs écritures d'un seul coup. Une telle lecture exécutée en avance d'une autre écriture est appelée une '''lecture spéculative'''. Une lecture spéculative peut donc sauter devant une ou plusieurs écritures, qui seront appelées des '''écritures sautées'''. Une écriture sautée est une précédant une lecture spéculative, dans l'ordre du programme. Évidemment, il se peut qu'une écriture sautée écrive la donnée qui est lue par la lecture spéculative. Il y a alors une violation de dépendance mémoire. Mais le processeur suppose qu'il n'y a pas de dépendantes de ce type et émet la lecture en avance. Rassurez-vous, il surveille quand même la situation, pour tout annuler ensuite s'il détecte une violation de dépendance mémoire. J'ai dit plus haut que le processeur émet les lectures dans l'ordre, ainsi que les écritures dans l'ordre. Pour cela, la LSQ est scindée en deux files de µops : une pour les µops LOAD et une autre pour les µops STORE. Les cours d'architecture des ordinateurs nomment souvent ces deux files en utilisant les termes de ''file de STORE'' (''store queue'') et de ''file de LOAD'' (''load queue''), mais il y a un risque de confusion avec la file d'écriture vue précédemment, et la file de lecture qu'on introduira dans la suite. Dans ce qui suit, nous parlerons de '''file de µops LOAD''' et de '''file de µops STORE''', pour éviter toute confusion. Les deux files sont chacune connectées à l'unité mémoire, via un port dédié. L'unité mémoire a donc un port pour les lectures, et un port pour les écritures. Le port d'écriture est relié à la file d'écriture, le port de lecture est directement relié au cache de données. Il faut noter qu'une unité mémoire à deux ports n'est pas si bizarre que cela. En effet, le cache de données a lui aussi deux ports, dans la majorité des cas, avec un port de lecture et un port d'écriture. [[File:Load Ordering, Store Ordering.jpg|centre|vignette|upright=2|Exécution dans le désordre des lectures.]] Maintenant, voyons comment sont détectées les violations de dépendances mémoire. Les dépendances qui peuvent être violées dans ce cas de figure se font entre une écriture sautée et une lecture spéculative. Si une lecture spéculative lit une donnée qui est écrite par une écriture sautée, alors une dépendance RAW a été violée. Pour les détecter, le processeur garde une trace des lectures spéculatives; tant que les instructions précédentes ne sont pas terminées. Pour cela, il utilise une file d'attente, qui mémorise les lecture spéculatives. Nous l'appellerons la ''file de vérification des lectures'', ou encore la '''file de lectures'''. Elle est une sorte d'équivalent de la file d'écriture, mais pour les lectures. Les lectures sortent de la file µops LOAD dès que leurs adresses sont calculées, sans tenir compte des dépendances mémoires. Elles sont alors ajoutées à la file de lectures et s'exécutent immédiatement, de manière spéculative. Pour vérifier qu'il n'y a pas d'erreur de spéculation, le processeur conserve l'adresse de la lecture effectuée dans la file de lectures. Les lectures quittent la file de lecture quand toutes les instructions précédentes dans l'ordre du programme sont terminées, à savoir tant quand elles ont quitté le tampon de réordonnancement/ROB. Notons que la file de vérification des lectures contient les lectures dans l'ordre du programme. En effet, les lectures sont accumulées en sortant de la file de µops LOAD, où elles sont triées dans l'ordre du programme. Et cela permet de gérer les erreurs de prédiction parfaitement, en annulant les instructions suivant une erreur de prédiction. [[File:Spéculation avec une file de lecture.jpg|centre|vignette|upright=2.5|Spéculation avec une file de lecture.]] Reste à détecter les erreurs de prédiction, ce qui peut être fait avec deux méthodes. La première détecte les violations de dépendances mémoire à chaque écriture. L'idée est que chaque écriture vérifie si une lecture anticipée n'a pas lue la donnée écrite en avance. Pour cela, quand une écriture est émise, elle consulte la file de lecture pour voir s'il y a un conflit d'adresse. La vérification fait cependant gaffe à ne prendre en compte que les lectures situées après l'écriture dans l'ordre du programme. Précisément, elle envoie l'adresse à écrire à la file de lecture et cherche si une lecture ultérieure a eu lieu à la même adresse. Si c'est le cas, cela signifie qu'une lecture anticipée aurait du lire la donnée actuellement écrite, mais que la lecture a été exécutée trop en avance. La lecture n'a pas lu la bonne donnée, il y a eu une violation de dépendance mémoire. Pour faire cette vérification, la file de lecture est un mélange entre FIFO et cache/mémoire associative, sur le même modèle que la file d'écriture. La différence est que la consultation de la file de lecture ne sert pas à faire du contournement, mais à détecter les dépendances. Quand une dépendance est détectée, l'écriture se fait normalement. Par contre, la lecture marquée comme invalide dans la file de lecture. L'exécution du programme se poursuit, jusqu'à ce que la lecture arrive à l'étape de ''commit'', à savoir que le ROB lui dit que les instructions précédentes sont terminées sans encombre. Là, la lecture étant invalide, le processeur déclenche une procédure de réparation, qui vide partiellement le pipeline de toutes les instructions qui suivent la lecture. La lecture est de plus ré-executée, afin de lire la bonne donnée. Avec la seconde méthode, les violations de dépendance mémoire sont détectées au moment où la lecture quitte à la file de lectures et le ROB. L'idée est de comparer la donnée lue de manière spéculative avec la donnée lue de manière non-spéculative. Pour faire la comparaison, la lecture spéculative copie la donnée lue dans la file de lecture. Quand la lecture quitte la file de lecture et le ROB, la lecture est ré-exécutée et la donnée relue. Le processeur compare alors la donnée lue à ce moment avec la donnée mémorisée dans la file de lecture. Si la donnée est différente, alors il y a eu erreur de violation de dépendance mémoire. Évidemment, relire une donnée n'est pas gratuit. Il est possible de limiter son impact en performance en dédiant un port de lecture sur le cache rien que pour ça. Mais le cout en circuit est comparable à l'usage d'une FIFO associative de la technique précédente. Mais quoi qu'il en soit, le cache doit permettre des lectures en un seul cycle, sans quoi la méthode ne marche pas vraiment. ===La prédiction de dépendances mémoires=== Certains processeurs essayent de prédire si deux accès mémoires sont dépendants : ils incorporent une unité qui va fonctionner comme une unité de prédiction de branchement, à la différence qu'elle prédit les dépendances mémoires. Si cette unité prédit que deux accès mémoires sont indépendants, le processeur les exécute dans le désordre, et les exécute dans l'ordre du programme dans le cas contraire. Il faut prendre en compte les erreurs de prédiction, ce qui est fait comme pour les mauvaises prédictions de branchement : on vide le pipeline. Beaucoup de techniques de prédiction des dépendances mémoire ont été inventées et en faire une revue exhaustive serait long et fastidieux. On pourrait citer les ensembles colorés (''color sets''), et bien d'autres techniques. Mais nous n'allons parler que des techniques les plus élémentaires. Quoi qu’il en soit, la recherche sur le sujet est assez riche, comme toujours quand il s'agit d'architecture des ordinateurs. On peut améliorer cette technique en mémorisant les instructions qui ont causé une mauvaise prédiction de dépendance mémoire. La prochaine fois qu'on exécute ces instructions, on sait qu'il y a de grandes chances qu'il se produise une erreur de prédiction, ce qui pousse à ne pas les exécuter de manière spéculative. On peut pour cela réutiliser les techniques de prédiction de branchement, tels des compteurs à saturations, mis à jour en cas d'erreurs de prédiction de dépendances mémoire. Dans tous les cas, on trouve un cache, équivalent au ''branch target buffer'', qui mémorise les instructions fautives (leur ''Program Counter''), avec d'autres informations comme les adresses des instructions dépendantes. La première classe de techniques du genre consiste à mémoriser les lectures qui ont causé une violation de dépendance, tandis que l'autre ne mémorise que les écritures. Cette dernière est l'approche utilisée par le '''cache de barrières d’écriture''' (store barrier cache). Enfin, nous allons conclure avec une dernière technique : celle des '''ensembles d’écritures''' (store sets). Cette technique est capable de gérer le cas où une lecture dépend de plusieurs écritures : le processeur mémorise l'ensemble des écritures avec lesquelles la lecture a déjà eu une dépendance. Cet ensemble d'écritures associées à une lecture est appelé tout simplement un ensemble d’écritures, et reçoit un identifiant (un nombre) par le processeur. Cette technique demande d'utiliser deux tables : * une qui assigne un identifiant d’ensemble d’écritures à l'instruction en cours ; * une autre qui mémorise les ensembles d’écritures sous la forme d'une liste chainée : le début de la liste correspond à l'écriture la plus ancienne de l’ensemble. Cela permet d'obtenir l'écriture la plus ancienne dans cet ensemble d’écritures directement. Quand le processeur détecte une violation de dépendance entre une lecture et une écriture, elle ajoute l'écriture dans l’ensemble d’écritures adéquat. Le déroulement d'une lecture demande d'accéder à la première table pour récupérer l'identifiant de l’ensemble d’écritures, et l'utiliser pour adresser la seconde table. S'il y a dépendance, cet accès renvoie l'écriture fautive en cas de dépendance. Quand une écriture est envoyée à l'unité mémoire, celle-ci va accéder à la table de correspondances de la même manière qu'une lecture, et va récupérer l'identifiant de l’ensemble d’écritures auquel elle appartient, identifiant qui est utilisé pour vérifier s'il y a une écriture en cours d’exécution. Si c'est le cas, cela signifie que l'écriture en cours d’exécution doit s’exécuter avant l'écriture qui a consulté la table : cette dernière est mise en attente. ==La prédiction d'adresse et de valeur== D'autres techniques de prédiction mémoire plus élaborées que les précédentes existent. Il s'agit de la prédiction d'adresse et de la prédiction de valeur. Leur nom trahit ce qu'elles font. La première technique tente de prédire l'adresse d'une lecture/écriture, alors que la seconde tente carrément de prédire quelle sera la donnée lue. Il y a bien de la recherche académique sur le sujet, mais il s'agit de techniques assez osées, dont on voit mal comment elles pourraient fonctionner. L'efficacité de cette technique a été étudiée grâce à des simulateurs. Suivant les études ou les programmes, on trouve des résultats qui varient pas mal. Dans certains cas, la performance baisse un peu, et dans d'autres, on peut avoir des gains de plus de 10% ! Mais dans des cas normaux, on trouve des gains de 4-5% environ. Ce qui est tout de même pas mal à l'heure actuelle. Cependant, en Janvier 2025, à l'heure où j'écris ces lignes, il a été découvert la première utilisation de ces deux techniques dans un processeur commercial. La découverte fait suite à la publication de deux vulnérabilités matérielles sur les CPU Apple M2 et M4 et quelques CPU ARM, qui font justement usage de ces deux techniques. Les deux vulnérabilités, appelées SLAP et FLOP, sont assez similaires aux attaques Spectre et Meltdown, sauf qu'elles attaquent non pas la prédiction de branchement, mais la prédiction d'adresse et de valeur du CPU. ===La prédiction d'adresse=== La prédiction d'adresse tente de prédire l'adresse d'une lecture à l'avance, ce qui permet de lancer les lectures en avance. Si la prédiction est correcte, on gagne quelques cycles qui permet aux unités de calcul de faire leurs calculs plus en avance, sans compter que les mécanismes de désambiguïsation mémoire fonctionnent plus efficacement. Si jamais cette adresse est connue plus tôt, on détecte les dépendances plus rapidement et on peut agir en conséquence. Des variantes de ces unités de prédiction d'adresse sont utilisées pour précharger des données depuis le cache de donnée. Et leur efficacité est assez bonne, voire excellente dans certains cas. La différence est que la prédiction d'adresse est utilisée pour prédire les adresses à lire depuis le cache vers les registres, ce qui est le sujet de cette sous-partie. La prédiction d'adresse est réalisée par une sorte de cache, qui associe à chaque instruction la prochaine adresse à lire. Nous appellerons ce cache la '''table de prédiction d'adresse'''. Pour faire l'association instruction <-> adresse lue/écrite, le tag du cache utilise l'adresse de l'instruction (le ''program Counter''), alors que la ligne de cache associée contient l'adresse prédite. Son contenu est utilisé pour faire une prédiction. Lorsqu'une lecture est émise, la table est consultée pour récupérer en avance l'adresse à lire, avant que celle-ci soit calculée par l'ALU. La lecture est alors exécutée en avance, avant que la vraie adresse soit connue, en utilisant l'adresse prédite. Reste qu'il faut des circuits autour de cette table de prédiction d'adresse, pour déterminer si la prédiction est correcte, ainsi que pour remplir cette table avec une adresse prédite. Pour cela, il y a un circuit de prédiction d'adresse dédié. Il récupère les instructions qui quittent le ROB ou la file des lectures mémoire, afin de vérifier les lectures qui viennent de se terminer/''commit'' et qui quittent le pipeline. Il peut contenir une '''table d'apprentissage''', qui mémorise des informations nécessaires pour faire des prédictions d'adresse. Par exemple, un historique des dernières adresses lues pour les lectures les plus récentes, ou un historique des adresses des lectures. La table est aussi utilisée pour déterminer si les prédictions effectuées étaient correctes ou non. Pour cela, il est possible de réutiliser les techniques vues dans le chapitre sur la prédiction de branchement, notamment des compteurs à saturation. Pour la prédiction de branchement et les autres formes de prédiction mémoire, les table d'apprentissage et pour les instructions/adresses sont fusionnées en une seule. Mais ici, il est nécessaire de les séparer pour une raison assez particulière : éliminer la pollution du cache dans la table de prédiction d'adresse. On ne peut pas allouer une ligne de cache pour chaque nouvelle instruction de lecture exécutée. Seule une minorité des lectures profitent de la prédiction d'adresse. Il est nécessaire de filtrer les lectures pour ne conserver que celles qui profitent de la prédiction d'adresse, ce qui est possible plus facilement en séparant la table d'apprentissage et les compteurs à saturation de la table de prédiction d'adresse. Une première méthode consiste à prédire que la lecture lit toujours la même adresse, ce qui lui vaut le nom de '''prédiction d'adresse constante'''. On pourrait se demander pourquoi une telle situation surviendrait. La raison est qu'il arrive que les compilateurs n'arrivent pas à gérer les accès mémoires efficacement. Dans certaines situations un peu compliquées impliquant des pointeurs ou des références, divers phénomènes complexes d'''aliasing'' des pointeurs peuvent générer des relectures intempestives de données en mémoire. Cela peut aussi arriver lorsqu'on compile du code qui provient de plusieurs librairies, ou quand du code lit régulièrement des constantes en mémoire ROM. La prédiction d'adresse constante ajoute un circuit qui analyse les dernières lectures, une fois qu'elles quittent le ROB, qu'elles ''commit''. Si, pour une instruction de lecture, l'adresse lue est toujours la même, alors l'instruction est ajoutée dans la table de prédiction d'adresse. Vu que les instructions qui accèdent toujours à la même adresse sont rares, il est préférable d'initialiser les compteurs à saturation de façon à ce qu'ils disent que toute nouvelle instruction change d'adresse. Il s'agit d'une méthode simple mais peu efficace, car cette situation est rare. Une autre méthode détecte les accès en enjambées, là encore quand les lectures quittent le ROB. Nous l’appellerons la '''prédiction d'adresse en enjambées'''. De tels accès surviennent régulièrement quand on accède des tableaux ou d'autres structures de données semblables, ce qui rend la technique très intéressante. Implémenter cette technique est facile : il suffit d'ajouter un circuit qui détecte les accès en enjambées, au lieu de celui qui détecte les accès constants. À chaque fois qu'une lecture entraine un succès de cache dans la table de prédiction d'adresse, l'enjambée est ajoutée à l'adresse contenue dans la table de prédiction d'adresse, l'ancienne adresse prédite est remplacée par celle avec l'enjambée d'ajoutée. Sur les Apple M2-M4, la prédiction d'adresse gére aussi bien les adresses constantes que les accès en enjambée. Le brevet qui décrit l'implémentation de l'unité de prédiction d'adresse et de valeur est potentiellement le suivant : [https://patents.google.com/patent/US11829763B2/en Early load execution via constant address and stride prediction]. En théorie, il est possible de faire mieux en repérant des accès mémoires qui se répètent de façon régulière et cyclique, même s'ils n'ont aucune enjambée. Ce genre d'accès se trouve assez souvent lorsque l'on manipule des listes chainées ou des structures de données assez irrégulières comme des arbres, des graphes, etc. Mais il n'y a pas encore de CPU qui implémente de telle optimisation. ===La prédiction de valeur : prédire la donnée lue=== Les techniques de '''prédiction de valeur''' (''Value Prediction'') consistent à prédire quelle est la donnée qui sera lue par une instruction de lecture. Oui, vous avez bien lu : le processeur est capable de parier sur la valeur qui sera chargée depuis la mémoire, de prédire si cette valeur vaut 0, 1, 1024, etc. Une fois son pari fait, il exécute les instructions du programme avec la valeur qu'il a pariée de façon spéculative. Si le pari est correct, le CPU continue l’exécution. Sinon, il est obligé de vider le pipeline et de recommencer avec la bonne valeur. Au premier abord, cette technique semble franchement mal partie tellement les chances de se tromper sont tellement énormes ! Mais le fait est que, dans certains cas, il est possible de spéculer correctement. Là encore, les techniques les plus simples fonctionnent dans deux cas. Le premier est celui où une donnée est relue à l'identique à chaque fois que la lecture est exécutée. Le second est celui où la donnée est incrémentée/décrémentée d'une constante qui est toujours la même d'une exécution de la lecture à l'autre. Une sorte d'enjambée constante, mais pour les données. Nous allons nous concentrer sur le premier cas, celui où une donnée est relue plusieurs fois sans être modifiée entre temps. L'idée de base est qu'une instruction de lecture qui s'exécute plusieurs fois de suite à la même adresse renvoie la même valeur à chaque fois. C'est parfaitement possible s’il n'y a eu aucune écriture à cette adresse entre-temps, d'où le fait qu'il faille surveiller si les prédictions constantes sont correctes. On peut se demander quelles sont les raisons qui font qu'une instruction de lecture renvoie la même valeur à chaque fois. Après tout, autant lire une seule fois la donnée et la garder dans un registre une bonne fois pour toutes ! Mais dans la réalité, on fait face à quelques limites, qui seront détaillées dans la liste suivante. La première est que le manque de registre fait que certaines données sont conservées en RAM et sont relues fréquemment. Cela arrive quand on stocke des constantes en mémoire. Par exemple, sur les processeurs x86, les constantes flottantes ne peuvent pas être intégrées dans nos instructions via le mode d'adressage immédiat. À la place, on les stocke en mémoire et on les charge dans les registres à chaque fois qu'on en a besoin. L'implémentation est similaire à la prédiction d'adresse, si ce n'est que c'est la donnée lue qui est prédite. Là encore, on a une '''table de prédiction de donnée lue''', consultée lorsqu'une lecture est émise. La table de prédiction de la donnée lue est une mémoire cache qui associe le ''Program Counter'' de la lecture à la donnée prédite, éventuellement couplé à des compteurs à saturation. Elle est consultée à chaque fois qu'une lecture est émise, la donnée adéquate est récupérée lors d'un succès de cache. On trouve aussi un circuit qui insère ou retire des instructions de la table de prédiction de donnée lue. Elle détecte les lectures prédictibles, couplé à une table d’apprentissage, et quelques circuits annexes liés au ROB ou aux files de lectures. Elle contient au minimum des compteurs à saturations, associés chacun à une instruction (son ''program counter'' pour être précis). ==L'optimisation du ''Mirroring memory operand''== Les processeurs modernes implémentent une optimisation concernant les accès mémoire. Cette optimisation a été décrite par Agner Fog dans ses manuels d'optimisation, sous le terme de '''''Mirroring memory operand'''''. Il a documenté son apparition sur les processeurs Zen 2 d'AMD, puis son évolution sur les processeurs Zen 4 et 5. L'optimisation est bizarrement absente de la microarchitecture Zen 3. Elle se manifeste dans une condition très précise : plusieurs instructions proches accèdent à la même adresse. Les deux instructions ne sont pas forcément consécutives, il peut y avoir une dizaine ou centaine d'instructions entre les deux. Une telle situation survient souvent sur les CPU CISC, et notamment sur les CPU x86, car ils ont peu de registres. les compilateurs font donc beaucoup d'échanges entre pile d'appel et registres, ce qui fait que des données écrites dans la pile sont souvent relue quelques instructions plus tard. Dans ce cas, la donnée lue/écrite est mémorisée dans un registre interne au processeur et peut être accédée très rapidement. Par exemple, imaginons plusieurs lectures proches à la même adresse, sans écriture entre elles. Dans ce cas, le processeur ne fera qu'une seule lecture en mémoire RAM, les lectures ultérieures liront la donnée depuis le registre interne. Comme autre exemple, imaginons une écriture suivie plus tard par une lecture à la même adresse. Dans ce cas, la donnée sera écrite dans un registre interne, en plus d'être propagée dans le cache ou la RAM, et la lecture ultérieure lira directement le registre interne. Le gain en performance est alors conséquent, comparé à du simple ''store-to-load forwarding''. Il faut noter que la technique marche aussi pour les opérations ''load-op'', pas seulement pour les instructions d'accès mémoire. Par exemple, prenons une écriture en RAM, suivies quelques instructions plus tard par une opération ''load-op'' à la même adresse. L'écriture écrit à une adresse, qui est lue par l'instruction ''load-op''. L'optimisation s'applique : l'instruction ''load-op'' lira l'opérande mémoire depuis un registre interne du processeur, qui contient une copie de la donnée écrite. Sur les processeurs Zen 2, l'optimisation marchait aussi pour les instructions PUSH et POP, qui lisaient/écrivaient dans la pile d'appel. Mais cette possibilité a été retirée sur les processeurs Zen 5. La technique détecte que deux instructions accèdent à la même adresse sous certaines conditions bien précises. L'optimisation ne marche que pour les modes d'adressages indicés et indirect, avec ou sans décalages, mais pas en mode absolu ou relatifs. Il faut préciser que l'optimisation ne marche que pour les registres généraux, pas les registres flottants. Les registres doivent être précisés de la même manière dans toutes les instructions. Par exemple, le processeur ne reconnait pas que l'addition des deux registres RCX + RAX est la même que RAX + RCX. Sur les CPU Zen 2 et 4, elle ne marchait que pour des opérandes de 32 et 64 bits, mais les processeurs Zen 5 gèrent des données de 8 et 16 bits aussi. Les décalages étaient limités à 8 bits sur le Zen 2, mais n'a plus de limites sur les Zen 4 et 5. ==La désambiguïsation mémoire a des effets de bord== La désambiguïsation mémoire a une caractéristique que l'exécution dans le désordre simple n'a pas : elle a des effets qui débordent sur la mémoire. Il est possible de savoir si un processeur fait de la désambiguïsation mémoire en regardant le bus mémoire. On s’aperçoit alors que les lectures se font dans un ordre différent de celui du programme. À l'opposé, l'exécution dans le désordre n'a pas d'effets observables de ce genre. Elle améliore la performance, elle modifie les durées observables des instructions, mais cela se limite à des questions de ''timings''. Le seul moyen de savoir si un processeur fait de l'exécution dans le désordre est de mesurer les latences/durées de chaque instruction. Mais aucun état observable au-delà des ''timings'' n'est modifié. Pas avec la désambiguïsation mémoire. ===La consistance mémoire=== Le fait que la désambiguïsation mémoire ait des effets observables ne pose aucun problème avec un seul processeur, ca elle est conçue pour. Par contre, les techniques de désambiguïsation mémoire peuvent poser des problèmes avec plusieurs processeurs. Il arrive que des programmes partagent une même portion de mémoire, et lisent/écrivent dedans. Avec plusieurs processeurs, chaque processeur effectue la désambiguïsation mémoire dans son coin sans se préoccuper des autres. Les opérations d'écriture peuvent être mises dans le désordre du point de vue des autres processeurs, et les lectures effectuées par les autres processeurs peuvent alors renvoyer de vielles données. Un exemple classique est celui de deux cœurs qui partagent le même cache ou la même mémoire, mais qui utilisent chacun une file d'écriture (''store buffer''). Imaginons que le premier cœur effectue une écriture, puis une lecture, à des adresses différentes. L'écriture est retardée grâce au tampon d'écriture, alors que la lecture lit directement le cache. Pour le premier processeur, l'écriture s'est réalisée avant la lecture, car pour lui, la fin de l'écriture signifie écrire dans le tampon d'écriture. Mais pour le second cœur, l'ordre s'est inversé : il voit la lecture dans le cache, puis l'écriture dans le cache. La raison est qu'il n'a pas connaissance du contenu du tampon d'écriture de l'autre cœur. Un autre exemple : les techniques de prédiction d'adresse et de valeur posent des problèmes avec les dépendances RAW. Elles permettent à une instruction mémoire de s'exécuter avant une autre, malgré la présence d'une dépendance RAW entre les deux. L'ordre entre les deux instructions change, alors que les autres processeurs ne devraient pas le voir. Et dans les faits, les processeurs qui incorporent ces techniques ont des modèles de consistance mémoire qui ne permettent pas ces optimisations en cas de dépendances RAW. Les deux techniques doivent idéalement être désactivées pour les instructions mémoire qui ont des dépendances RAW. Les programmeurs doivent tenir compte de ce genre de situations, dans des cas assez rares et liés à la programmation bas niveau. Pour cela, ils doivent savoir comment le processeur réorganise les accès mémoire. Concrètement, chaque processeur définit un '''modèle de consistance mémoire''' qui indique quelles optimisations sont activées et non. Il s'agit d'une sorte de contrat entre le logiciel et le matériel : les programmeurs savent comment le processeur va réorganiser les lectures et écritures et peuvent coder leurs programmes en fonction. L'implémentation du système de consistance mémoire dépend grandement de l'architecture, mais des modèles ont été standardisés afin de permettre aux programmeurs de savoir où placer les barrières mémoires. Il s'agit de vrais standards, formalisés, parfois mathématiquement, que des chercheurs étudient sur un plan mathématique et algorithmique. Le modèle préféré des programmeurs est la '''consistance séquentielle''', où les différents processeurs effectuent des accès mémoire dans l'ordre du programme et les accès mémoire simultanés sont interdits. La première contrainte interdit l'usage de mécanismes de désambiguïsation mémoire, la seconde interdit l'usage de caches non-bloquants. La consistance séquentielle a donc un cout en performance assez important, ce qui fait que les processeurs modernes n'utilisent pas la consistance séquentielle. Un modèle plus flexible est le '''''total store ordering''''', qui autorise la présence d'une file d'écriture et l'usage de caches non-bloquants pour les lectures (interdit d'avoir plusieurs écritures simultanées). C'est plus ou moins celui utilisé par les processeurs x86, comme on le verra plus bas. Il existe des modèles de consistance mémoire plus relâchés, appelés des '''modèles de consistance faible''', qui autorisent diverses optimisations de spéculation mémoire, la combinaison d'écriture (fusionner deux écritures à la même adresse en une seule), etc. Ils sont supportés sur les architectures ARM et POWER. ===Les barrières mémoire=== Le changement d'ordre des lectures et écritures peut poser occasionnellement des problèmes avec la programmation pour les architectures multi-processeur ou multicœurs. Il existe des morceaux de code qui ne marchent que si la consistance séquentielle est respectée et il faut faire en sorte qu'ils marchent sur des processeurs avec un modèle de consistance mémoire différent. Pour cela, les processeurs incorporent des instructions appelées '''barrières mémoires''' ou '''''Fences'''''. Elles forcent le processeur à terminer tous les accès mémoire qui précédent dans l'ordre du programme. Certains processeurs possèdent une barrière mémoire pour les lectures et une autre pour les écritures. D'autres processeurs ajoutent des barrières mémoires dédiées à des cas particuliers. Elles permettent de forcer les accès à des données partagées à dérouler correctement. [[File:Fences.png|centre|vignette|upright=2|Fences.]] Leur implémentation est assez simple : elles empêchent l'émission de toute nouvelle instruction mémoire, tant qu'une condition précise n'est pas réunie. Par exemple, la barrière mémoire pour les écritures attend que la file d'écriture soit vidée avant de démarrer le moindre accès mémoire. Un compilateur peut placer les barrières mémoires au bon endroit, avec un peu d'aide du programmeur. Certains langages de programmation permettent d'indiquer au compilateur qu'une donnée doit toujours être lue depuis la mémoire RAM, via le mot-clé volatile. C'est très utile pour préciser que cette donnée est potentiellement partagée par plusieurs processeurs ou manipulable par des périphériques. Les compilateurs peuvent placer des barrières mémoires lors des lectures ou écritures sur ces variables, mais ce n'est pas une obligation. Il arrive aussi que le programmeur doive manipuler explicitement des barrières mémoires. Utiliser l'assembleur est alors une possibilité, mais qui est rarement exploitée, pour des raisons de portabilité. Pour limiter la casse, certains systèmes d'exploitations ou compilateurs peuvent aussi fournir des barrières mémoires explicites, encapsulées dans des bibliothèques ou cachées dans certaines fonctions. ===La modèle de consistance des processeurs x86=== Après avoir vu la théorie, passons maintenant à la pratique. Dans cette partie, on va voir les modèles de consistances utilisés sur les processeurs x86, ceux qu'on retrouve dans les PC actuels. Le modèle de consistance des processeurs x86 a varié au cours de l'existence de l'architecture : un vulgaire 486DX n'a pas le même modèle de consistance qu'un Core 2 duo, par exemple. Ils ont évolués au cours du temps, avec l'arrivée de nouvelles optimisations. De plus, leur formalisation a été progressive, et s'est faite au fur et à mesure. En général, les modèles de consistance des processeurs x86 ont toujours étés assez restrictifs, si on les compare aux autres processeurs. Il s'agit de modèles de type ''total store ordering'' (TSO), au moins dans les grandes lignes. Ils permettent donc d'effectuer certaines lectures en avance, soit parce que les lectures se font à des adresses différentes, soit par utilisation de réacheminement écriture vers lecture. Les possibilités d'exécution en avance des lectures varient suivant le processeur, elles deviennent de plus en plus performantes avec le temps. Le premier modèle de consistance est apparu sur les premiers processeurs x86 et est resté en place sur tous les processeurs de marque Pentium. Les processeurs de l'époque n'incorporaient pas beaucoup d'optimisations, le budget en transistor n'était pas assez conséquent pour ça, la mémoire n'était pas assez lente. Le modèle TSO de l'époque n'autorisaient que peu de lectures anticipées. Une lecture ne peut être exécutée en avance que sous les conditions suivantes : * les écritures contournées se font dans la mémoire cache ; * la lecture doit se faire dans la mémoire RAM ; * les écritures se font à une adresse différente de la lecture ; * aucune transaction avec un périphérique ne doit être en cours. A partir du Pentium 4, les choses changent. Le Pentium 4 est en effet le premier processeur à implémenter des techniques permettant d’exécuter plusieurs processus en parallèle, dont l'Hyperthreading. En conséquence, le modèle de consistance a dû être assoupli pour éviter de perdre bêtement en performance. Il s'agit d'un vrai modèle de type TSO, la majeure partie des contraintes sur les lectures anticipées sont réduites au minimum. Il y a aussi des contraintes supplémentaires pour les instructions dites atomiques, ainsi que pour les instructions qui accèdent aux périphériques ou aux entrées-sorties. Une optimisation séparée du TSO est que les écritures en mémoire peuvent changer d'ordre dans un cas exceptionnel : les écritures effectuées par les instructions de copie mémoire comme REPMOVSD, REPSCACB, ainsi que les instructions SSE MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, MOVNTPD. Les écritures effectuées dans ces instructions peuvent se faire dans un désordre complet (ou presque). Lors du passage au 64 bits, le modèle mémoire des processeurs x86 de l'époque a été formalisé dans un papier d'Intel appelé [https://www.cs.cmu.edu/~410-f10/doc/Intel_Reordering_318147.pdf Intel® 64 Architecture Memory Ordering White Paper], mis à jour en 2008 suite à quelques problèmes techniques. La formalisation du modèle a été d'une grande aide aux programmeurs, qui devaient se débrouiller avec des documentations qui ne formalisaient pas de modèle de consistance précis. Le document décrit un modèle de consistance mémoire portant le nom barbare de ''total lock order + causal consistency” (TLO+CC)'', qui admet plus d'optimisations que le modèle TSO. Mais la description était en réalité légèrement différente du modèle mémoire réellement implémenté sur les processeurs existants, qui utilisaient du TSO, sauf en de rares occasions. Les concepteurs de processeur font souvent cela, à savoir définir un modèle de consistance plus laxiste que réellement implémenté, pour des questions de comptabilité. Ainsi, si les futurs processeurs de la marque implémentent un modèle plus laxiste, les programmes déjà codés continuent de fonctionner. Le modèle décrit dans la documentation laisse de la marge pour le futur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le parallélisme mémoire | nextText=Le parallélisme mémoire }} </noinclude> bd4ty679q5mjtxd1ai635yc1qcdj960 Fonctionnement d'un ordinateur/L'exécution dans le désordre 0 65869 774111 774093 2026-10-04T12:34:33Z Mewtow 31375 774111 wikitext text/x-wiki Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter. Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore. Mais avant de poursuivre, quelques point niveaux terminologie : * L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle. * Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''. * Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage. Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''. L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions. [[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]] Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit. [[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']] Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre. L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire. ==Les ''schedulers'' et les accès mémoire== Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse. Les processeurs modernes ajoutent des techniques d'exécution dans le désordre des accès mémoire, mais ces techniques de '''''désambiguïsation mémoire''''' seront vues dans un chapitre dédié. Elles permettent d'utiliser un ''scheduler'' unique, qui mémorise aussi les µops mémoire. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gèére les dépendances d'adresse. [[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]] Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] La file de µops est reliée à un ''scoreboard'' qui détecte émettre quand la µops la plus ancienne. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''. De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle. Dans ce qui suit, on port du principe que les µops mémoire sont scindées en deux : une µop de calcul d'adresse, et une µops mémoire. La première calcule l'adrese utilisée par la seconde, la seconde éxecute l'accès mémoire dans le cache. La séparation permet d'exécuter les calculs d'adresse dans le désordre, mais les µops mémoire se font elles dans l'ordre. Le transfert de l'adresse calculée se fait à travers un ''registre d'adresse'' "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. ==L'implémentation d'un ''scheduler'' : généralités== Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture. Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus. Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées. [[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']] ===Les stations de réservation=== Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout. [[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]] Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder. Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires. [[File:Processeur avec plusieurs fenêtres d'instruction.png|centre|vignette|upright=2.5|Lecture après émission.]] [[File:Read Before Issue.png|centre|vignette|upright=2.5|Lecture avant émission.]] ===La logique d'éveil et de sélection=== Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances. Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''. [[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]] La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques. Déjà, un processeur a généralement des unités séparées pour les µops entières et flottantes, des unités spécialisées dans les accès mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul, on ne doit pas envoyer une µop entière dans une unité de calcul flottante. De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir plusieurs unités de calcul spécialisées dans les opérations simples (additions, soustractions, opérations logiques), mais une seule unité pour les multiplications/divisions, une seule unité spécialisée pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée. Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge. Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus. ===La logique d'éveil : l'émission anticipée=== La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement. Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. : Il faut noter que le problème se pose plus avec des ''schedulers'' qu'avec des stations de réservation. Les premières ont un étage en plus entre émission et ALU, vu qu'il faut lire des registres après l'émission. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé. L'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, ce qui n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. L'unité de composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré. L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée. L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée. ===La logique de sélection : âge ou position ?=== La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur lé numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''scheduler''. Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''. La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Le circuit de sélection ne se résume donc pas seulement à un encodeur à priorité, mais c'est le circuit central. L'avantage d'une telle méthode de sélection est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter. Mais on remarque un défaut : un encodeur est un circuit assez rapide, mais qui utilise beaucoup de transistors, beaucoup de portes logiques, surtout si on veut qu'il soit rapide. Ce qui explique que la logique de sélection ait son propre étage de pipeline rien que pour elle. La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. Compacter le ''scheduler'', à savoir regrouper toutes les entrées valides est une idée, mais elle est compliquée à implémenter. Aussi, elles préfèrent encoder des informations sur l'ordre d'émission dans chaque entrée. L'encodeur à priorité doit alors travailler non pas sur des nombres entiers liés à l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente. Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient une méthode intermédiaire. Ils utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position. ===La compaction des ''scheduler''=== À chaque cycle, les micro-opérations décodées sont ajoutées dans le ''scheduler'', dans des entrées vides. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes. Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple. ==Les ''schedulers'' basées sur une matrice de dépendances== L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre. ===Rappels sur le registre de réservation/disponibilité=== L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise. Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre). {|class="wikitable" |+ Registre de réservation |- ! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0 |- | 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0 |} Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise. [[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]] Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul. ===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits=== La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués. L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission. [[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]] L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m. [[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]] Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail. [[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]] ==Les ''schedulers'' basées sur une mémoire associative== Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées. Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement. [[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]] En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même. [[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]] L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple. Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi. ===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées=== Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion. Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits. Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long. L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''. [[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]] Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc. Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout. ===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission=== D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible. La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite. Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' : * une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ; * une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ; * une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée. C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle. ===Les optimisations de la consommation d'énergie avancées=== D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles. Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs. Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales. ===Remplacer la mémoire associative par une RAM=== Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux. La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs. Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée. [[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]] ==Les ''schedulers'' avec préplanification== Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat. [[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]] ===L'usage de FIFO multiples=== La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate. A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre. [[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]] ===La technique du tampon trié=== La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée. [[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]] La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts. L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut. [[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]] Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification. [[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]] ===Les ''scheduler'' avec allocation temporelle statique=== L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences. L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance. L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise. Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire. Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro. Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles. Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles. Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée. Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction ! La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les premiers processeurs Intel | prevText=Les premiers processeurs Intel | next=Le renommage de registres | nextText=Le renommage de registres }} </noinclude> oq1qwyema8nmosreryl38s8fcmkol1z 774112 774111 2026-10-04T12:40:02Z Mewtow 31375 /* Les schedulers et les accès mémoire */ 774112 wikitext text/x-wiki Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter. Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore. Mais avant de poursuivre, quelques point niveaux terminologie : * L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle. * Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''. * Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage. Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''. L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions. [[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]] Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit. [[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']] Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre. L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire. [[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]] ==Les ''schedulers'' et les accès mémoire== Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse. Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] La file de µops est reliée à un ''scoreboard'' qui détecte émettre quand la µops la plus ancienne. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''. De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle. Dans ce qui suit, on port du principe que les µops mémoire sont scindées en deux : une µop de calcul d'adresse, et une µops mémoire. La première calcule l'adrese utilisée par la seconde, la seconde éxecute l'accès mémoire dans le cache. La séparation permet d'exécuter les calculs d'adresse dans le désordre, mais les µops mémoire se font elles dans l'ordre. Le transfert de l'adresse calculée se fait à travers un ''registre d'adresse'' "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Les processeurs modernes ajoutent des techniques d'exécution dans le désordre des accès mémoire, mais ces techniques de '''''désambiguïsation mémoire''''' seront vues dans un chapitre dédié. Elles permettent d'utiliser un ''scheduler'' unique, qui mémorise aussi les µops mémoire. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gèére les dépendances d'adresse. ==L'implémentation d'un ''scheduler'' : généralités== Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture. Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus. Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées. [[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']] ===Les stations de réservation=== Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout. [[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]] Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder. Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires. [[File:Processeur avec plusieurs fenêtres d'instruction.png|centre|vignette|upright=2.5|Lecture après émission.]] [[File:Read Before Issue.png|centre|vignette|upright=2.5|Lecture avant émission.]] ===La logique d'éveil et de sélection=== Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances. Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''. [[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]] La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques. Déjà, un processeur a généralement des unités séparées pour les µops entières et flottantes, des unités spécialisées dans les accès mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul, on ne doit pas envoyer une µop entière dans une unité de calcul flottante. De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir plusieurs unités de calcul spécialisées dans les opérations simples (additions, soustractions, opérations logiques), mais une seule unité pour les multiplications/divisions, une seule unité spécialisée pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée. Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge. Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus. ===La logique d'éveil : l'émission anticipée=== La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement. Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. : Il faut noter que le problème se pose plus avec des ''schedulers'' qu'avec des stations de réservation. Les premières ont un étage en plus entre émission et ALU, vu qu'il faut lire des registres après l'émission. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé. L'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, ce qui n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. L'unité de composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré. L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée. L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée. ===La logique de sélection : âge ou position ?=== La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur lé numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''scheduler''. Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''. La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Le circuit de sélection ne se résume donc pas seulement à un encodeur à priorité, mais c'est le circuit central. L'avantage d'une telle méthode de sélection est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter. Mais on remarque un défaut : un encodeur est un circuit assez rapide, mais qui utilise beaucoup de transistors, beaucoup de portes logiques, surtout si on veut qu'il soit rapide. Ce qui explique que la logique de sélection ait son propre étage de pipeline rien que pour elle. La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. Compacter le ''scheduler'', à savoir regrouper toutes les entrées valides est une idée, mais elle est compliquée à implémenter. Aussi, elles préfèrent encoder des informations sur l'ordre d'émission dans chaque entrée. L'encodeur à priorité doit alors travailler non pas sur des nombres entiers liés à l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente. Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient une méthode intermédiaire. Ils utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position. ===La compaction des ''scheduler''=== À chaque cycle, les micro-opérations décodées sont ajoutées dans le ''scheduler'', dans des entrées vides. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes. Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple. ==Les ''schedulers'' basées sur une matrice de dépendances== L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre. ===Rappels sur le registre de réservation/disponibilité=== L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise. Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre). {|class="wikitable" |+ Registre de réservation |- ! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0 |- | 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0 |} Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise. [[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]] Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul. ===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits=== La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués. L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission. [[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]] L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m. [[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]] Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail. [[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]] ==Les ''schedulers'' basées sur une mémoire associative== Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées. Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement. [[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]] En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même. [[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]] L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple. Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi. ===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées=== Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion. Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits. Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long. L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''. [[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]] Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc. Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout. ===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission=== D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible. La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite. Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' : * une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ; * une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ; * une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée. C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle. ===Les optimisations de la consommation d'énergie avancées=== D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles. Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs. Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales. ===Remplacer la mémoire associative par une RAM=== Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux. La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs. Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée. [[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]] ==Les ''schedulers'' avec préplanification== Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat. [[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]] ===L'usage de FIFO multiples=== La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate. A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre. [[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]] ===La technique du tampon trié=== La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée. [[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]] La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts. L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut. [[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]] Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification. [[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]] ===Les ''scheduler'' avec allocation temporelle statique=== L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences. L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance. L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise. Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire. Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro. Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles. Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles. Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée. Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction ! La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les premiers processeurs Intel | prevText=Les premiers processeurs Intel | next=Le renommage de registres | nextText=Le renommage de registres }} </noinclude> dhha125srd0ow22pf1xe3blvrer7x4x 774113 774112 2026-10-04T12:44:49Z Mewtow 31375 /* Les schedulers et les accès mémoire */ 774113 wikitext text/x-wiki Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter. Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore. Mais avant de poursuivre, quelques point niveaux terminologie : * L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle. * Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''. * Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage. Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''. L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions. [[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]] Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit. [[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']] Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre. L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire. [[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]] ==Les ''schedulers'' et les accès mémoire== Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse. Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] La file de µops est reliée à un ''scoreboard'' qui détecte émettre quand la µops la plus ancienne. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''. De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela. Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle. Dans ce qui suit, on port du principe que les µops mémoire sont scindées en deux : une µop de calcul d'adresse, et une µops mémoire. La première calcule l'adrese utilisée par la seconde, la seconde éxecute l'accès mémoire dans le cache. La séparation permet d'exécuter les calculs d'adresse dans le désordre, mais les µops mémoire se font elles dans l'ordre. Le transfert de l'adresse calculée se fait à travers un ''registre d'adresse'' "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. ==L'implémentation d'un ''scheduler'' : généralités== Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture. Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus. Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées. [[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']] ===Les stations de réservation=== Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout. [[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]] Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder. Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires. [[File:Processeur avec plusieurs fenêtres d'instruction.png|centre|vignette|upright=2.5|Lecture après émission.]] [[File:Read Before Issue.png|centre|vignette|upright=2.5|Lecture avant émission.]] ===La logique d'éveil et de sélection=== Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances. Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''. [[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]] La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques. Déjà, un processeur a généralement des unités séparées pour les µops entières et flottantes, des unités spécialisées dans les accès mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul, on ne doit pas envoyer une µop entière dans une unité de calcul flottante. De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir plusieurs unités de calcul spécialisées dans les opérations simples (additions, soustractions, opérations logiques), mais une seule unité pour les multiplications/divisions, une seule unité spécialisée pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée. Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge. Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus. ===La logique d'éveil : l'émission anticipée=== La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement. Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. : Il faut noter que le problème se pose plus avec des ''schedulers'' qu'avec des stations de réservation. Les premières ont un étage en plus entre émission et ALU, vu qu'il faut lire des registres après l'émission. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé. L'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, ce qui n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. L'unité de composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré. L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée. L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée. ===La logique de sélection : âge ou position ?=== La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur lé numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''scheduler''. Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''. La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Le circuit de sélection ne se résume donc pas seulement à un encodeur à priorité, mais c'est le circuit central. L'avantage d'une telle méthode de sélection est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter. Mais on remarque un défaut : un encodeur est un circuit assez rapide, mais qui utilise beaucoup de transistors, beaucoup de portes logiques, surtout si on veut qu'il soit rapide. Ce qui explique que la logique de sélection ait son propre étage de pipeline rien que pour elle. La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. Compacter le ''scheduler'', à savoir regrouper toutes les entrées valides est une idée, mais elle est compliquée à implémenter. Aussi, elles préfèrent encoder des informations sur l'ordre d'émission dans chaque entrée. L'encodeur à priorité doit alors travailler non pas sur des nombres entiers liés à l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente. Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient une méthode intermédiaire. Ils utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position. ===La compaction des ''scheduler''=== À chaque cycle, les micro-opérations décodées sont ajoutées dans le ''scheduler'', dans des entrées vides. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes. Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple. ==Les ''schedulers'' basées sur une matrice de dépendances== L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre. ===Rappels sur le registre de réservation/disponibilité=== L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise. Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre). {|class="wikitable" |+ Registre de réservation |- ! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0 |- | 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0 |} Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise. [[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]] Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul. ===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits=== La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués. L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission. [[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]] L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m. [[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]] Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail. [[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]] ==Les ''schedulers'' basées sur une mémoire associative== Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées. Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement. [[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]] En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même. [[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]] L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple. Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi. ===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées=== Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion. Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits. Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long. L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''. [[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]] Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc. Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout. ===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission=== D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible. La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite. Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' : * une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ; * une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ; * une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée. C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle. ===Les optimisations de la consommation d'énergie avancées=== D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles. Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs. Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales. ===Remplacer la mémoire associative par une RAM=== Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux. La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs. Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée. [[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]] ==Les ''schedulers'' avec préplanification== Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat. [[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]] ===L'usage de FIFO multiples=== La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate. A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre. [[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]] ===La technique du tampon trié=== La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée. [[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]] La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts. L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut. [[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]] Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification. [[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]] ===Les ''scheduler'' avec allocation temporelle statique=== L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences. L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance. L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise. Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire. Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro. Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles. Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles. Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée. Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction ! La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les premiers processeurs Intel | prevText=Les premiers processeurs Intel | next=Le renommage de registres | nextText=Le renommage de registres }} </noinclude> o92ujm7n3zr52zrh88gl449vddr1xty 774114 774113 2026-10-04T12:45:28Z Mewtow 31375 /* Les schedulers et les accès mémoire */ 774114 wikitext text/x-wiki Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter. Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore. Mais avant de poursuivre, quelques point niveaux terminologie : * L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle. * Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''. * Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage. Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''. L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions. [[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]] Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit. [[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']] Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre. L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire. [[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]] ==Les ''schedulers'' et les accès mémoire== Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse. Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] La file de µops est reliée à un ''scoreboard'' qui détecte émettre quand la µops la plus ancienne. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''. De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela. Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle. ==L'implémentation d'un ''scheduler'' : généralités== Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture. Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus. Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées. [[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']] ===Les stations de réservation=== Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout. [[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]] Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder. Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires. [[File:Processeur avec plusieurs fenêtres d'instruction.png|centre|vignette|upright=2.5|Lecture après émission.]] [[File:Read Before Issue.png|centre|vignette|upright=2.5|Lecture avant émission.]] ===La logique d'éveil et de sélection=== Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances. Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''. [[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]] La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques. Déjà, un processeur a généralement des unités séparées pour les µops entières et flottantes, des unités spécialisées dans les accès mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul, on ne doit pas envoyer une µop entière dans une unité de calcul flottante. De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir plusieurs unités de calcul spécialisées dans les opérations simples (additions, soustractions, opérations logiques), mais une seule unité pour les multiplications/divisions, une seule unité spécialisée pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée. Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge. Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus. ===La logique d'éveil : l'émission anticipée=== La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement. Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. : Il faut noter que le problème se pose plus avec des ''schedulers'' qu'avec des stations de réservation. Les premières ont un étage en plus entre émission et ALU, vu qu'il faut lire des registres après l'émission. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé. L'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, ce qui n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. L'unité de composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré. L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée. L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée. ===La logique de sélection : âge ou position ?=== La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur lé numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''scheduler''. Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''. La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Le circuit de sélection ne se résume donc pas seulement à un encodeur à priorité, mais c'est le circuit central. L'avantage d'une telle méthode de sélection est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter. Mais on remarque un défaut : un encodeur est un circuit assez rapide, mais qui utilise beaucoup de transistors, beaucoup de portes logiques, surtout si on veut qu'il soit rapide. Ce qui explique que la logique de sélection ait son propre étage de pipeline rien que pour elle. La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. Compacter le ''scheduler'', à savoir regrouper toutes les entrées valides est une idée, mais elle est compliquée à implémenter. Aussi, elles préfèrent encoder des informations sur l'ordre d'émission dans chaque entrée. L'encodeur à priorité doit alors travailler non pas sur des nombres entiers liés à l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente. Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient une méthode intermédiaire. Ils utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position. ===La compaction des ''scheduler''=== À chaque cycle, les micro-opérations décodées sont ajoutées dans le ''scheduler'', dans des entrées vides. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes. Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple. ==Les ''schedulers'' basées sur une matrice de dépendances== L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre. ===Rappels sur le registre de réservation/disponibilité=== L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise. Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre). {|class="wikitable" |+ Registre de réservation |- ! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0 |- | 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0 |} Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise. [[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]] Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul. ===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits=== La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués. L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission. [[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]] L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m. [[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]] Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail. [[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]] ==Les ''schedulers'' basées sur une mémoire associative== Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées. Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement. [[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]] En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même. [[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]] L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple. Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi. ===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées=== Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion. Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits. Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long. L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''. [[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]] Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc. Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout. ===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission=== D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible. La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite. Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' : * une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ; * une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ; * une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée. C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle. ===Les optimisations de la consommation d'énergie avancées=== D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles. Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs. Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales. ===Remplacer la mémoire associative par une RAM=== Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux. La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs. Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée. [[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]] ==Les ''schedulers'' avec préplanification== Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat. [[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]] ===L'usage de FIFO multiples=== La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate. A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre. [[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]] ===La technique du tampon trié=== La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée. [[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]] La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts. L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut. [[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]] Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification. [[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]] ===Les ''scheduler'' avec allocation temporelle statique=== L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences. L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance. L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise. Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire. Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro. Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles. Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles. Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée. Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction ! La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les premiers processeurs Intel | prevText=Les premiers processeurs Intel | next=Le renommage de registres | nextText=Le renommage de registres }} </noinclude> k7pl2kvzjs3oqd1up3fy0jhwy9ktv2m 774116 774114 2026-10-04T13:05:33Z Mewtow 31375 /* Les stations de réservation */ 774116 wikitext text/x-wiki Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter. Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore. Mais avant de poursuivre, quelques point niveaux terminologie : * L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle. * Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''. * Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage. Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''. L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions. [[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]] Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit. [[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']] Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre. L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire. [[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]] ==Les ''schedulers'' et les accès mémoire== Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse. Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] La file de µops est reliée à un ''scoreboard'' qui détecte émettre quand la µops la plus ancienne. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''. De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela. Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle. ==L'implémentation d'un ''scheduler'' : généralités== Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture. Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus. Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées. [[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']] ===Les stations de réservation=== Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout. [[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]] Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder. Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires. [[File:Read after issue non superscalaire.png|centre|vignette|upright=2.5|Lecture après émission.]] [[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2.5|Lecture avant émission.]] ===La logique d'éveil et de sélection=== Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances. Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''. [[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]] La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques. Déjà, un processeur a généralement des unités séparées pour les µops entières et flottantes, des unités spécialisées dans les accès mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul, on ne doit pas envoyer une µop entière dans une unité de calcul flottante. De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir plusieurs unités de calcul spécialisées dans les opérations simples (additions, soustractions, opérations logiques), mais une seule unité pour les multiplications/divisions, une seule unité spécialisée pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée. Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge. Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus. ===La logique d'éveil : l'émission anticipée=== La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement. Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. : Il faut noter que le problème se pose plus avec des ''schedulers'' qu'avec des stations de réservation. Les premières ont un étage en plus entre émission et ALU, vu qu'il faut lire des registres après l'émission. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé. L'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, ce qui n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. L'unité de composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré. L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée. L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée. ===La logique de sélection : âge ou position ?=== La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur lé numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''scheduler''. Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''. La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Le circuit de sélection ne se résume donc pas seulement à un encodeur à priorité, mais c'est le circuit central. L'avantage d'une telle méthode de sélection est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter. Mais on remarque un défaut : un encodeur est un circuit assez rapide, mais qui utilise beaucoup de transistors, beaucoup de portes logiques, surtout si on veut qu'il soit rapide. Ce qui explique que la logique de sélection ait son propre étage de pipeline rien que pour elle. La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. Compacter le ''scheduler'', à savoir regrouper toutes les entrées valides est une idée, mais elle est compliquée à implémenter. Aussi, elles préfèrent encoder des informations sur l'ordre d'émission dans chaque entrée. L'encodeur à priorité doit alors travailler non pas sur des nombres entiers liés à l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente. Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient une méthode intermédiaire. Ils utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position. ===La compaction des ''scheduler''=== À chaque cycle, les micro-opérations décodées sont ajoutées dans le ''scheduler'', dans des entrées vides. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes. Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple. ==Les ''schedulers'' basées sur une matrice de dépendances== L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre. ===Rappels sur le registre de réservation/disponibilité=== L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise. Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre). {|class="wikitable" |+ Registre de réservation |- ! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0 |- | 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0 |} Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise. [[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]] Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul. ===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits=== La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués. L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission. [[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]] L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m. [[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]] Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail. [[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]] ==Les ''schedulers'' basées sur une mémoire associative== Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées. Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement. [[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]] En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même. [[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]] L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple. Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi. ===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées=== Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion. Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits. Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long. L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''. [[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]] Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc. Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout. ===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission=== D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible. La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite. Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' : * une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ; * une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ; * une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée. C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle. ===Les optimisations de la consommation d'énergie avancées=== D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles. Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs. Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales. ===Remplacer la mémoire associative par une RAM=== Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux. La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs. Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée. [[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]] ==Les ''schedulers'' avec préplanification== Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat. [[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]] ===L'usage de FIFO multiples=== La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate. A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre. [[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]] ===La technique du tampon trié=== La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée. [[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]] La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts. L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut. [[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]] Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification. [[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]] ===Les ''scheduler'' avec allocation temporelle statique=== L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences. L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance. L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise. Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire. Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro. Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles. Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles. Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée. Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction ! La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les premiers processeurs Intel | prevText=Les premiers processeurs Intel | next=Le renommage de registres | nextText=Le renommage de registres }} </noinclude> qs2v6pteqthjug37la3zf8ccptpuo4y 774117 774116 2026-10-04T13:06:20Z Mewtow 31375 /* Les stations de réservation */ 774117 wikitext text/x-wiki Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter. Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore. Mais avant de poursuivre, quelques point niveaux terminologie : * L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle. * Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''. * Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage. Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''. L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions. [[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]] Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit. [[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']] Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre. L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire. [[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]] ==Les ''schedulers'' et les accès mémoire== Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse. Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] La file de µops est reliée à un ''scoreboard'' qui détecte émettre quand la µops la plus ancienne. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''. De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela. Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle. ==L'implémentation d'un ''scheduler'' : généralités== Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture. Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus. Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées. [[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']] ===Les stations de réservation=== Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout. [[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]] Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder. Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires. {| |[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]] |[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]] |} ===La logique d'éveil et de sélection=== Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances. Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''. [[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]] La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques. Déjà, un processeur a généralement des unités séparées pour les µops entières et flottantes, des unités spécialisées dans les accès mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul, on ne doit pas envoyer une µop entière dans une unité de calcul flottante. De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir plusieurs unités de calcul spécialisées dans les opérations simples (additions, soustractions, opérations logiques), mais une seule unité pour les multiplications/divisions, une seule unité spécialisée pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée. Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge. Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus. ===La logique d'éveil : l'émission anticipée=== La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement. Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. : Il faut noter que le problème se pose plus avec des ''schedulers'' qu'avec des stations de réservation. Les premières ont un étage en plus entre émission et ALU, vu qu'il faut lire des registres après l'émission. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé. L'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, ce qui n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. L'unité de composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré. L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée. L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée. ===La logique de sélection : âge ou position ?=== La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur lé numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''scheduler''. Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''. La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Le circuit de sélection ne se résume donc pas seulement à un encodeur à priorité, mais c'est le circuit central. L'avantage d'une telle méthode de sélection est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter. Mais on remarque un défaut : un encodeur est un circuit assez rapide, mais qui utilise beaucoup de transistors, beaucoup de portes logiques, surtout si on veut qu'il soit rapide. Ce qui explique que la logique de sélection ait son propre étage de pipeline rien que pour elle. La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. Compacter le ''scheduler'', à savoir regrouper toutes les entrées valides est une idée, mais elle est compliquée à implémenter. Aussi, elles préfèrent encoder des informations sur l'ordre d'émission dans chaque entrée. L'encodeur à priorité doit alors travailler non pas sur des nombres entiers liés à l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente. Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient une méthode intermédiaire. Ils utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position. ===La compaction des ''scheduler''=== À chaque cycle, les micro-opérations décodées sont ajoutées dans le ''scheduler'', dans des entrées vides. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes. Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple. ==Les ''schedulers'' basées sur une matrice de dépendances== L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre. ===Rappels sur le registre de réservation/disponibilité=== L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise. Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre). {|class="wikitable" |+ Registre de réservation |- ! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0 |- | 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0 |} Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise. [[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]] Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul. ===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits=== La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués. L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission. [[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]] L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m. [[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]] Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail. [[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]] ==Les ''schedulers'' basées sur une mémoire associative== Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées. Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement. [[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]] En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même. [[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]] L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple. Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi. ===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées=== Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion. Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits. Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long. L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''. [[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]] Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc. Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout. ===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission=== D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible. La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite. Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' : * une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ; * une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ; * une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée. C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle. ===Les optimisations de la consommation d'énergie avancées=== D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles. Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs. Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales. ===Remplacer la mémoire associative par une RAM=== Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux. La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs. Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée. [[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]] ==Les ''schedulers'' avec préplanification== Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat. [[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]] ===L'usage de FIFO multiples=== La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate. A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre. [[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]] ===La technique du tampon trié=== La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée. [[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]] La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts. L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut. [[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]] Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification. [[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]] ===Les ''scheduler'' avec allocation temporelle statique=== L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences. L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance. L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise. Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire. Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro. Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles. Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles. Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée. Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction ! La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les premiers processeurs Intel | prevText=Les premiers processeurs Intel | next=Le renommage de registres | nextText=Le renommage de registres }} </noinclude> 7fvbtyr70938qp04ae7wms21wlrjrhx 774122 774117 2026-10-04T13:25:17Z Mewtow 31375 /* Les schedulers et les accès mémoire */ 774122 wikitext text/x-wiki Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter. Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore. Mais avant de poursuivre, quelques point niveaux terminologie : * L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle. * Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''. * Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage. Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''. L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions. [[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]] Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit. [[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']] Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre. L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire. [[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]] ==Les ''schedulers'' et les accès mémoire== Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse. Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] La file de µops est reliée à un ''scoreboard'' qui détecte émettre quand la µops la plus ancienne. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''. De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela. Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle. ==L'implémentation d'un ''scheduler'' : généralités== Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture. Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus. Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées. [[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']] ===Les stations de réservation=== Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout. [[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]] Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder. Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires. {| |[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]] |[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]] |} ===La logique d'éveil et de sélection=== Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances. Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''. [[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]] La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques. Déjà, un processeur a généralement des unités séparées pour les µops entières et flottantes, des unités spécialisées dans les accès mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul, on ne doit pas envoyer une µop entière dans une unité de calcul flottante. De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir plusieurs unités de calcul spécialisées dans les opérations simples (additions, soustractions, opérations logiques), mais une seule unité pour les multiplications/divisions, une seule unité spécialisée pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée. Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge. Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus. ===La logique d'éveil : l'émission anticipée=== La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement. Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. : Il faut noter que le problème se pose plus avec des ''schedulers'' qu'avec des stations de réservation. Les premières ont un étage en plus entre émission et ALU, vu qu'il faut lire des registres après l'émission. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé. L'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, ce qui n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. L'unité de composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré. L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée. L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée. ===La logique de sélection : âge ou position ?=== La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur lé numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''scheduler''. Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''. La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Le circuit de sélection ne se résume donc pas seulement à un encodeur à priorité, mais c'est le circuit central. L'avantage d'une telle méthode de sélection est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter. Mais on remarque un défaut : un encodeur est un circuit assez rapide, mais qui utilise beaucoup de transistors, beaucoup de portes logiques, surtout si on veut qu'il soit rapide. Ce qui explique que la logique de sélection ait son propre étage de pipeline rien que pour elle. La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. Compacter le ''scheduler'', à savoir regrouper toutes les entrées valides est une idée, mais elle est compliquée à implémenter. Aussi, elles préfèrent encoder des informations sur l'ordre d'émission dans chaque entrée. L'encodeur à priorité doit alors travailler non pas sur des nombres entiers liés à l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente. Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient une méthode intermédiaire. Ils utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position. ===La compaction des ''scheduler''=== À chaque cycle, les micro-opérations décodées sont ajoutées dans le ''scheduler'', dans des entrées vides. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes. Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple. ==Les ''schedulers'' basées sur une matrice de dépendances== L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre. ===Rappels sur le registre de réservation/disponibilité=== L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise. Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre). {|class="wikitable" |+ Registre de réservation |- ! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0 |- | 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0 |} Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise. [[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]] Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul. ===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits=== La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués. L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission. [[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]] L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m. [[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]] Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail. [[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]] ==Les ''schedulers'' basées sur une mémoire associative== Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées. Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement. [[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]] En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même. [[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]] L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple. Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi. ===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées=== Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion. Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits. Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long. L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''. [[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]] Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc. Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout. ===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission=== D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible. La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite. Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' : * une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ; * une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ; * une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée. C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle. ===Les optimisations de la consommation d'énergie avancées=== D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles. Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs. Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales. ===Remplacer la mémoire associative par une RAM=== Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux. La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs. Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée. [[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]] ==Les ''schedulers'' avec préplanification== Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat. [[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]] ===L'usage de FIFO multiples=== La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate. A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre. [[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]] ===La technique du tampon trié=== La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée. [[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]] La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts. L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut. [[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]] Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification. [[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]] ===Les ''scheduler'' avec allocation temporelle statique=== L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences. L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance. L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise. Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire. Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro. Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles. Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles. Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée. Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction ! La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les premiers processeurs Intel | prevText=Les premiers processeurs Intel | next=Le renommage de registres | nextText=Le renommage de registres }} </noinclude> lajauo85wcxtmdbbxybhosh35wj8nzl 774127 774122 2026-10-04T13:28:50Z Mewtow 31375 774127 wikitext text/x-wiki Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter. Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore. Mais avant de poursuivre, quelques point niveaux terminologie : * L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle. * Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''. * Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage. Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''. L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions. [[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]] Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit. [[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']] Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre. ==Les ''schedulers'' centralisés et les accès mémoire== L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire. [[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]] Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse. Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] La file de µops est reliée à un ''scoreboard'' qui détecte émettre quand la µops la plus ancienne. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''. De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela. Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle. ==L'implémentation d'un ''scheduler'' : généralités== Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture. Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus. Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées. [[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']] ===Les stations de réservation=== Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout. [[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]] Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder. Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires. {| |[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]] |[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]] |} ===La logique d'éveil et de sélection=== Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances. Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''. [[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]] La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques. Déjà, un processeur a généralement des unités séparées pour les µops entières et flottantes, des unités spécialisées dans les accès mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul, on ne doit pas envoyer une µop entière dans une unité de calcul flottante. De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir plusieurs unités de calcul spécialisées dans les opérations simples (additions, soustractions, opérations logiques), mais une seule unité pour les multiplications/divisions, une seule unité spécialisée pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée. Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge. Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus. ===La logique d'éveil : l'émission anticipée=== La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement. Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. : Il faut noter que le problème se pose plus avec des ''schedulers'' qu'avec des stations de réservation. Les premières ont un étage en plus entre émission et ALU, vu qu'il faut lire des registres après l'émission. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé. L'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, ce qui n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. L'unité de composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré. L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée. L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée. ===La logique de sélection : âge ou position ?=== La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur lé numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''scheduler''. Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''. La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Le circuit de sélection ne se résume donc pas seulement à un encodeur à priorité, mais c'est le circuit central. L'avantage d'une telle méthode de sélection est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter. Mais on remarque un défaut : un encodeur est un circuit assez rapide, mais qui utilise beaucoup de transistors, beaucoup de portes logiques, surtout si on veut qu'il soit rapide. Ce qui explique que la logique de sélection ait son propre étage de pipeline rien que pour elle. La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. Compacter le ''scheduler'', à savoir regrouper toutes les entrées valides est une idée, mais elle est compliquée à implémenter. Aussi, elles préfèrent encoder des informations sur l'ordre d'émission dans chaque entrée. L'encodeur à priorité doit alors travailler non pas sur des nombres entiers liés à l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente. Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient une méthode intermédiaire. Ils utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position. ===La compaction des ''scheduler''=== À chaque cycle, les micro-opérations décodées sont ajoutées dans le ''scheduler'', dans des entrées vides. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes. Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple. ==Les ''schedulers'' basées sur une matrice de dépendances== L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre. ===Rappels sur le registre de réservation/disponibilité=== L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise. Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre). {|class="wikitable" |+ Registre de réservation |- ! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0 |- | 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0 |} Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise. [[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]] Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul. ===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits=== La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués. L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission. [[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]] L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m. [[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]] Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail. [[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]] ==Les ''schedulers'' basées sur une mémoire associative== Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées. Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement. [[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]] En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même. [[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]] L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple. Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi. ===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées=== Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion. Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits. Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long. L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''. [[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]] Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc. Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout. ===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission=== D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible. La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite. Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' : * une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ; * une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ; * une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée. C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle. ===Les optimisations de la consommation d'énergie avancées=== D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles. Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs. Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales. ===Remplacer la mémoire associative par une RAM=== Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux. La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs. Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée. [[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]] ==Les ''schedulers'' avec préplanification== Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat. [[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]] ===L'usage de FIFO multiples=== La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate. A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre. [[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]] ===La technique du tampon trié=== La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée. [[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]] La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts. L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut. [[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]] Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification. [[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]] ===Les ''scheduler'' avec allocation temporelle statique=== L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences. L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance. L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise. Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire. Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro. Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles. Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles. Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée. Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction ! La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les premiers processeurs Intel | prevText=Les premiers processeurs Intel | next=Le renommage de registres | nextText=Le renommage de registres }} </noinclude> f5py2cvfjt7hf0fvxt41ha5ag14k2hv 774157 774127 2026-10-04T16:30:20Z Mewtow 31375 /* Les schedulers centralisés et les accès mémoire */ 774157 wikitext text/x-wiki Dans les chapitres précédents, nous avons parlé des techniques dites d''''émission dans l'ordre'''. L'idée est d’exécuter une nouvelle micro-opération à chaque cycle, dans des unités de calcul séparées. Les instructions sont émises consécutivement, mais les instructions s’exécutent en parallèle dans des unités de calcul séparées. Par contre, quand l'unité d'émission ne peut pas émettre une instruction, elle bloque tout le pipeline. Les instructions qui suivent sont elles aussi bloquées, elles doivent attendre leur tour, même si elles sont en capacité de s'exécuter. Les techniques d'exécution dans le désordre que nous allons voir dans ce chapitre corrigent ce point précis. Elles sont dites à '''émission dans le désordre'''. Elles se distinguent des précédentes sur un point précis : quand l'unité d'émission bloque l'émission d'une instruction, les instructions suivantes peuvent poursuivre. L'émission des instructions peut donc se faire dans le désordre, dans le sens où une instruction peut être émise alors qu'une instruction précédente ne l'est pas encore. Mais avant de poursuivre, quelques point niveaux terminologie : * L'émission dans le désordre est un terme parlant et assez précis. Malheureusement, la terminologie la plus utilisée par d''''exécution dans le désordre'''. Le terme anglais exact est ''Out Of Order Execution'' et son abréviation OOO. Et nous utiliserons ce terme dans la suite du cours, histoire de ne pas trop dévier de la nomenclature habituelle. * Un processeur qui utilise l'exécution dans le désordre sera appelé un '''processeur ''out of order''''', alor qu'un processeur qui ne l'utilise pas sera dit '''''in-order'''''. * Nous utiliserons l'abréviation ''µop'' pour parler de micro-opération, ''µops'' au pluriel pour parler de plusieurs micro-opérations, ou des micro-opérations en général. L'émission dans le désordre ne gère en effet que des µops, elle se situe après l'étape de décodage. Pour décrire comment est implémentée l'émission dans le désordre, il faut commencer par parler des processeurs sans. Les processeurs ''in-order'' ont une unité d'émission qui bloque tout le pipeline dès qu'une µop ne peut pas être émise. Pour limiter l'impact de ce blocage, le processeur incorpore une '''file de micro-opération''', une mémoire FIFO qui met en attente les µops. Vu que c'est une mémoire FIFO, les µops sont émises dans l'ordre, à savoir l'ordre d'ajout, l'ordre de décodage. Le schéma ci-dessous illustre une telle file d'attente couplée à un ''scoreboard''. L'exécution dans le désordre améliore la file de µops sur un point : ce n'est plus une mémoire FIFO. Les µops sont insérées dans l'ordre, mais peuvent sortir dans le désordre (être émises dans le désordre). La file de µops devient alors un '''''scheduler''''', terme anglais que je ne compte pas traduire. Un terme équivalent est '''''scheduler''''', terme traduit directement de l'anglais ''instruction Window''. Terme assez trompeur, car elles mettent en attente des µops, pas des instructions. [[File:Différence file µops et fenêtres instruction.png|centre|vignette|upright=2|Différence file µops et ''schedulers'' ]] Le ''scheduler'' émet une µop dès que ses opérandes sont disponibles. Par disponible, on veut dire présent soit dans le réseau de contournement, soit dans les registres. Le ''scheduler'' détecte automatiquement quand les opérandes d'une µop sont disponibles, grâce à une unité d'émission intégrée. L'unité d'émission et la file d'attente de µops sont fusionnés ensemble, dans un seul circuit, mais nous allons dire qu'ils sont conceptuellement séparés pour le moment. À chaque cycle, l'unité d'émission consulte les µops en attente pour détecter lesquelles peuvent s'exécuter. Le ''scheduler'' est spécialement être conçue pour, c'est une sorte de mémoire associative très complexe, comme on le verra dans ce qui suit. [[File:OOO Issue.png|centre|vignette|upright=2.5|Exécution dans le désordre avec un ''scheduler'']] Une fois qu'une µop sort du décodeur d'instructions, elle est ajoutée en parallèle dans le ''scheduler'' et le ROB. La raison est que les µops quittent le ''scheduler'' dans le désordre, et qu'elles doivent être remises en ordre par la suite. Le ROB s'occupe de cette remise en ordre, mais il a besoin de savoir dans quel ordre les µops sont censées être, et cet ordre est celui du décodage. Aussi, pour remplir le ROB, la seule opportunité est en sortie de l'unité de décodage. Dans le cas où une instruction est décodée en plusieurs micro-opérations, elles sont toutes insérées en même temps dans le ''scheduler'' et le ROB, en tenant compte de leur ordre. ==Les ''schedulers'' centralisés et les accès mémoire== L'implémentation la plus "simple" de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Les µops décodées sont accumulées dans le ''scheduler'', où elles attendent leur tour. Bien que simple en apparence, cette implémentation cache une complexité très importante, liée aux accès mémoire. [[File:Processeur avec exécution dans le désodre des accès mémoire.png|centre|vignette|upright=2|Processeur avec exécution dans le désodre des accès mémoire]] Les micro-opérations mémoire sont à part des autres, pour ce qui est de leurs dépendances de données. Non seulement elles ont des dépendances de registre, mais elles sont aussi des dépendances d'adresse. Par exemple, deux écritures peuvent utiliser deux registres d'adresse différents, mais ceux-ci peuvent contenir la même adresse. Et autant on peut détecter les dépendances de registre lors de l'émission, autant c'est plus compliqué avec les dépendances d'adresse. Les processeurs assez anciens n'autorisaient pas d'exécution dans le désordre des accès mémoire. Concrètement, les accès mémoire devaient se faire dans l'ordre, mais les autres µops pouvaient être exécutées dans le désordre. Pour garantir l'ordre des µops mémoire, les µops mémoire ne passent pas par le ''scheduler'', mais sont accumulées dans une file de micro-opérations séparée. Après le décodage, les µops sont réparties soit dans la file de µops mémoire, soit dans le ''scheduler''. Le décodeur classe les µops suivant que ce sont des µops mémoire ou non, et les envoient à la bonne destination. Dans ce chapitre, on supposera que c'est cette solution qui est utilisée. [[File:Processeur avec émission dans l'ordre des accès mémoire.png|centre|vignette|upright=2|Processeur avec émission dans l'ordre des accès mémoire]] La file de µops mémoire a son propre ''scoreboard'', pour savoir si la µops la plus ancienne est prête à être émise. L'usage d'une file de µops implique que la file de mémorise pas les opérandes des µops. L'adresse à lire/écrire est lue après l'émission, idem pour la donnée à écrire pour les écritures. Si l'unité mémoire fait les calculs d'adresse, ce sont les registres opérande qui sont lus. L'unité d'émission mémoire émet l'instruction, puis la µops lit les registres, et l'accès mémoire rentre dans l'unité mémoire et s'exécute. On parle alors de '''lecture après émission'''. De plus, rappelons les écritures doivent être retardées tant que les instructions précédentes ne sont pas terminées. Pour cela, le processeur les mets en attente dans une ''file d'écriture'', qui collabore avec le ROB. La file d'écriture envoie une écriture à la mémoire RAM quand le ROB lui dit que c'est bon, les instructions précédentes sont terminées ''dans l'ordre du programme''. [[File:File de µops mémoire avec calcul d'adresse dans l'unité mémoire.png|centre|vignette|upright=2|File de µops mémoire avec calcul d'adresse dans l'unité mémoire]] Les processeurs modernes peuvent exécuter les accès mémoire dans le désordre, grâce à des techniques de '''''désambiguïsation mémoire'''''. Si elles sont mises en place, les dépendances de registre sont traitées par le ''scheduler'', alors que les dépendances d'adresse sont gérées par l'unité mémoire gère les dépendances d'adresse. L'unité mémoire garde une trace de l'ordre des accès mémoire, elle met en attente les accès mémoire si besoin, elle décide quand les accès mémoire démarrent, etc. En somme, l'unité mémoire dispose de son propre ''scheduler'' intégré. Nous les verrons dans un chapitre dédié, ce qui fait que je ne détaille pas plus que cela. Tout ce qu'il faut retenir est que les µops mémoire sont traités à part des autres µops, pour l'exécution dans le désordre. Il y a le ''scheduler'' et une voie séparée pour les µops mémoire proprement dites. Et ce, peu importe que les accès mémoire soient faits dans l'ordre ou dans le désordre. Sans cela, le processeur ne peut pas fonctionner normalement. Un avantage de séparer les µops mémoire des autres, est que cela permet d'exécuter une instruction arithmétique en parallèle d'un accès mémoire. Mais les deux ne démarrent pas au même cycle : on émet d'abord l'accès mémoire, puis les instructions de calcul suivent au cycle suivant. Là, les deux démarrent au même cycle. ==L'implémentation d'un ''scheduler'' : généralités== Un ''scheduler'' est une mémoire qui mémorise des micro-opérations. Elle est cependant plus complexe qu'une mémoire RAM ou qu'une FIFO et ressemble un petit peu à une mémoire cache. Elle dispose d'un port d'écriture et d'un port de lecture, au minimum. Le port d'écriture permet à l'unité de décodage d'insérer une micro-opération dedans, d'ajouter la micro-opération qui vient d'être décodée. Aussi, il est appelé le '''port de décodage'''. Si une instruction machine est décodée en plusieurs micro-opérations, deux possibilités. La première est de les insérer un par un, ce qui fait qu'on peut se contenter d'un seul port d'écriture. L'autre possibilité est de les ajouter en même temps, ce qui demande plusieurs ports d'écriture. Le port de lecture sert à émettre une micro-opération, ce qui fait qu'il est appelé le '''port d'émission'''. Dans ce chapitre, on part du principe que le ''scheduler'' ne peut émettre qu'une seule micro-opération à la fois. Dans ce cas, le ''scheduler'' a un seul port de lecture. Mais nous verrons dans quelques chapitres qu'il existe des processeurs dits superscalaires, qui sont capables d'émettre plusieurs micro-opérations par cycle. Un processeur superscalaire a typiquement un port d'émission par unité de calcul, ce qui fait beaucoup plus. Les ''schedulers'' sont composées d''''entrées''', des mots mémoire qui stockent une micro-opération. Une micro-opération tout juste décodée réserve une entrée et la libère dès qu'elle est envoyée aux unités de calcul. Une entrée contient l'opcode (les signaux de commande à envoyer à l'ALU), le registre de destination du résultat, les registres de chaque opérande, et éventuellement des signaux de commande en plus. De plus, chaque opérande est couplée à un bit de disponibilité, qui indique si elle est disponible. Quand un opérande est écrit dans les registres, le bit de présence correspondant est mis à jour. Enfin, chaque entrée possède un bit ''empty'' qui indique si elle est vide, cette information étant utile pour réserver des entrées. [[File:Entrée d'une station de réservation - sans les tags.png|centre|vignette|upright=3|Entrée d'un ''scheduler'']] ===Les stations de réservation=== Les '''stations de réservation''' sont des ''scheduler'' particulières, qui sont capables de mémoriser les opérandes des micro-opérations. Les entrées des stations de réservation sont identiques à celle des autres ''scheduler'', si ce n'est que les opérandes de la micro-opération sont enregistrés directement dans des champs séparés. Il y a deux champs, un par opérande, pour stocker l'opérande elle-même, lue depuis les registres ou obtenue via le réseau de contournement. Notons que les champs pour les opérandes viennent en plus des champs pour les noms de registres, encore que les deux peuvent être fusionnés si on veut optimiser le tout. [[File:Entrée d'une station de réservation.png|centre|vignette|upright=3|Entrée d'une station de réservation.]] Précisons que la terminologie n'est pas très fiable. Les termes "''scheduler''" et "station de réservation" ne regroupent pas du tout la même chose d'un papier de recherche à l'autre, d'un bouquin à l'autre, d'un chercheur à l'autre, d'un ingénieur à l'autre, d'un professeur à l'autre. Le terme initial vient pourtant de l'article original sur l'algorithme de Tomasulo, où il désignait un ''scheduler'' associée à une unité de calcul précise. Mais le terme a ensuite été réutilisé, ce qui fait que certains processeurs avec plusieurs unités de calcul sont décrits comme ayant une unique station de réservation. En bref : c'est le bazar ! Mais nous en reparlerons dans le chapitre sur le renommage de registres, car c'est la place idéale pour les aborder. Toujours est-il que j'ai fait le choix de confondre les concepts de ''scheduler'' et de station de réservation avec les concepts de '''lecture avant émission''' et de '''lecture après émission'''. La différence entre les deux est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation. Dans le cas "avant émission", les opérandes sont mémorisés dans la station de réservation. Et cette différence a une influence sur le pipeline du processeur, le banc de registres et tout ce qui s'ensuit. Un désavantage de la lecture avant émission est qu'il faut stocker les opérandes après lecture, ici dans les stations de réservation. Mais un avantage est que le banc de registre a besoin de moins de ports de lecture, car une bonne partie des opérandes sont fournies par le système de contournement. Nous verrons cela dans le détail dans le chapitre sur les processeurs superscalaires. {| |[[File:Read after issue non superscalaire.png|centre|vignette|upright=2|Lecture après émission.]] |[[File:Read Before Issue non superscalaire.png|centre|vignette|upright=2|Lecture avant émission.]] |} ===La logique d'éveil et de sélection=== Dans le cas qui va suivre, on suppose que le processeur ne peut émettre qu'une micro-opération à la fois. Chaque cycle, une micro-opération est sélectionnée et est envoyée dans le chemin de données, soit à une ALU, soit à une unité d'accès mémoire. La sélection d'une micro-opération est effectuée par l'unité d'émission intégré au '''''scheduler'''''. Elle gère la disponibilité des opérandes et la disponibilité des unités de calcul adéquate. Le choix de la micro-opération émise doit être le plus pertinent possible, pour des raisons de performances. Pour le premier point, l'unité d'émission détermine quelles micro-opérations sont candidates pour l'émission. Les '''micro-opération candidates''' sont celles dont tous les opérandes sont prêts. Le processeur contient un circuit qui détecte les micro-opérations candidates : la '''logique d’éveil''' (''wake-up logic''). Une fois que la logique d'éveil a fait son travail, une micro-opération est choisie pour s’exécuter en fonction de la disponibilité des unités de calcul adéquates. Ce rôle est dévolu à un circuit qu'on appelle la '''logique de sélection''', ou ''select logic''. [[File:Fonctionnement complet de la logique d’émission.jpg|centre|vignette|upright=1.5|Fonctionnement complet de la logique d’émission.]] La logique de sélection doit gérer le fait que les unités de calcul du processeur ne sont pas toutes identiques. Déjà, un processeur a généralement des unités séparées pour les µops entières et flottantes, des unités spécialisées dans les accès mémoire, parfois des unités spécialisées pour les branchements. Une micro-opération doit être attribuée à la bonne unité de calcul, on ne doit pas envoyer une µop entière dans une unité de calcul flottante. De plus, parmi les unités de calcul entières, toutes ne sont pas identiques. Par exemple, il est fréquent d'avoir plusieurs unités de calcul spécialisées dans les opérations simples (additions, soustractions, opérations logiques), mais une seule unité pour les multiplications/divisions, une seule unité spécialisée pour les décalages/rotations, etc. Là encore, allouer la bonne micro-opération à la bonne ALU est complexe. Plus les unités de calcul sont hétérogènes, plus la logique de sélection est compliquée. Les logiques d'éveil comme de sélection sont assez complexes. Comme on le verra plus bas, la logique de sélection la plus simple, qui gère une seule unité de calcul, est basée sur un encodeur à priorité, couplé à d'autres circuits. Et pour gérer plusieurs ALUs, il faut mettre des encodeurs en cascade. Le résultat est que même en prenant une logique de sélection simple, elle sera assez lente et gourmande en circuits. Aussi, la logique de sélection met souvent un cycle d'horloge entier pour faire son travail, elle a son propre étage de pipeline attribué. La logique d'éveil est dans le même cas, ce qui fait qu'émettre une micro-opération demande deux cycles d'horloge. Le défaut est que cela rajoute des cycles d'horloges entre le ''scheduler'' et l'ALU, ce qui complique la gestion du pipeline. Ces cycles de retard sont à prendre en compte au moment d'émettre les micro-opérations, le ''scheduler'' doit en tenir compte pour des performances optimales. Sans tenir compte de ces cycles de retard, les micro-opérations arrivent en retard de quelques cycles à l'ALU, cycles de retard durant lesquels l'ALU n'a pas fait de calcul et a été sous-utilisée, sans compter que le système de contournement n'est pas utilisé au mieux. Et le problème est encore plus important sur les processeurs avec des stations de réservation, qui utilisent la "lecture après émission" : cela rajoute un cycle d'horloge en plus, un temps de retard en plus. ===La logique d'éveil : l'émission anticipée=== La logique d'éveil doit tenir compte du fait qu'il y a plusieurs étages entre l'émission et l'exécution sur une unité de calcul. Par exemple, un processeur a souvent entre un et à cinq étages entre la file d'attente et les unités de calcul. Il faut lire les registres, gérer le contournement, et cela peut prendre quelques cycles d'horloge. Sur le Pentium 4, on trouve 6 étages entre le ''scheduler'' et l'entrée de l'ALU. Si les micro-opérations sont émises quand les opérandes sont disponibles, il y aura un délai de quelques cycles avant qu'elles atteignent l'ALU. Alors qu'idéalement, on souhaiterait que la micro-opération atteigne l'ALU dès que ses opérandes sont disponibles, pour profiter des techniques de contournement. Pour cela, la logique d'éveil émet les micro-opérations quelques cycles en avance pour qu'elles arrivent au bon moment. Il s'agit d'une technique d''''émission anticipée''', dont nous avions parlé dans le chapitre sur le contournement. : Il faut noter que le problème se pose plus avec des ''schedulers'' qu'avec des stations de réservation. Les premières ont un étage en plus entre émission et ALU, vu qu'il faut lire des registres après l'émission. L'émission anticipée demande d'émettre un signal d'éveil en avance de quelques cycles, pile au bon moment. Par exemples, si on a deux étages entre l'ALU et l'unité d'émission, le signal de réveil sera généré deux cycles avant que le résultat soit effectivement calculé. L'implémentation varie, mais deux solutions sont possibles. La première est de générer le signal d'éveil dans l'ALU, ce qui n'a de sens que pour les opérations comme les multiplications ou les divisions, qui prennent plusieurs cycles. Une autre technique génère le signal de réveil dans une structure centralisée, spécialisée dans la génération des signaux d'éveil. L'unité de composée d'un registre à décalage par registre. Imaginons qu'une micro-opération est émise, que cette micro-opération mette N cycles à s'exécuter, et qu'elle écrive dans un registre de destination. L'unité sélectionne alors le registre à décalage associé au registre de destination, et met le bit numéro N à 1. Le registre décalé d'un cran à chaque cycle. Quand le bit sortant est un 1, alors le signal de validité pour l'opérande est généré. L'émission anticipée marche bien si la micro-opération a une durée fixe, connue à l'avance. Et autant c'est le cas pour les micro-opérations arithmétiques et logiques, autant ce n'est pas le cas pour les accès mémoire. Les accès mémoire ont une latence variable : quelques cycles pour un succès de cache, plusieurs dizaines de cycles pour un défaut de cache L1, pas loin de la centaine pour un défaut de cache L2, etc. Et cela pose des problèmes pour l'émission anticipée. L'émission en avance pour les accès mémoire est purement spéculative : le processeur émet des micro-opérations en supposant que la lecture entrainera un succès de cache, quitte à annuler la micro-opération en cas de défaut de cache. Mais faire ainsi demande non seulement d'annuler les micro-opérations émises en avance, mais aussi de ne pas retirer les micro-opérations émises en avance de le ''scheduler''. Elles sont émises, mais une copie de sauvegarde est conservée dans le ''scheduler''. Si le processeur détecte un défaut de cache, il peut alors ré-exécuter la micro-opération. S'il détecte un succès de cache, les micro-opérations lecture-dépendante sont retirées de le ''scheduler'', la copie de sauvegarde est devenue inutile et est donc jetée. ===La logique de sélection : âge ou position ?=== La logique de sélection est primordiale pour de bonnes performances. Il existe deux méthodes principales pour sélectionner la micro-opération à exécuter. La première est la plus simple : elle se base sur lé numéro de l'entrée. En effet, un ''scheduler'' est avant tout une sorte de mémoire, un mix entre mémoire associative, mémoire RAM, et FIFO. Et chaque entrée a un numéro, une adresse mémoire. Nous parlerons de numéro dans ce qui suit, car ce sera plus simple, le terme d'adresse mémoire étant peut-être un peu exagéré dans le contexte des ''scheduler''. Toujours est-il que le ''scheduler'' est adressable dans le sens où on peut lui envoyer le numéro de l'entrée voulue, et le ''scheduler'' renvoie le contenu de l'entrée sur son port de lecture. Et c'est ce que fait la logique de sélection : elle génère le numéro de l'entrée à lire. Le numéro est alors envoyé sur l'entrée d'adresse du ''scheduler'', et elle fournit la micro-opération associée sur son port de lecture, son '''port d'émission'''. La première méthode de sélection, donc. Elle est basée sur le numéro de l'entrée. Si plusieurs entrées sont éveillées, la logique de sélection prend celle avec le plus petit numéro. Elle porte le nom de '''sélection par position''', sous-entendu position dans le ''scheduler''. Vous l'avez peut-être deviné, mais la logique de sélection est alors un encodeur à priorité. Le circuit de sélection ne se résume donc pas seulement à un encodeur à priorité, mais c'est le circuit central. L'avantage d'une telle méthode de sélection est qu'elle est simple à implémenter : un encodeur à priorité est un circuit connu, facile à implémenter. Mais on remarque un défaut : un encodeur est un circuit assez rapide, mais qui utilise beaucoup de transistors, beaucoup de portes logiques, surtout si on veut qu'il soit rapide. Ce qui explique que la logique de sélection ait son propre étage de pipeline rien que pour elle. La seconde méthode s'appelle la '''sélection par âge''', au nom assez transparent. L'idée est de privilégier l'émission de la micro-opération la plus ancienne. Elle demande que le ''scheduler'' trie les micro-opérations par ordre d'insertion dans le ''scheduler'', ce qui en fait une pseudo-FIFO. Le tri est partiel, car les instructions peuvent sortir du ''scheduler'' à tout moment. Compacter le ''scheduler'', à savoir regrouper toutes les entrées valides est une idée, mais elle est compliquée à implémenter. Aussi, elles préfèrent encoder des informations sur l'ordre d'émission dans chaque entrée. L'encodeur à priorité doit alors travailler non pas sur des nombres entiers liés à l'ordre d'insertion dans la pseudo-FIFO. Elle est encore plus gourmande en circuits que la méthode précédente. Les processeurs haute performance utilisent souvent des méthodes hybrides. Par exemple, les processeurs AMD de microarchitecture Bulldozer utilisaient une méthode intermédiaire. Ils utilisaient un ''scheduler'' couplée à un encodeur à priorité pour la sélection par position, mais complémentaient le tout avec une unité qui mémorisait la micro-opération la plus ancienne dans le ''scheduler''. Le mécanisme de sélection par âge était utilisé en priorité. Mais si la micro-opération la plus ancienne n'était pas prête, le processeur basculait sur le mécanisme de sélection par position. ===La compaction des ''scheduler''=== À chaque cycle, les micro-opérations décodées sont ajoutées dans le ''scheduler'', dans des entrées vides. Vu que les micro-opérations quittent celle-ci dans le désordre, ces vides sont dispersés dans le ''scheduler'', ce qui pose problème pour déterminer où placer les nouvelles micro-opérations. La solution la plus triviale consiste à conserver une liste des vides, mise à jour à chaque insertion ou émission d'instruction. Une autre solution consiste à éliminer les vides en compactant le ''scheduler'' à chaque cycle d'horloge. Des circuits se chargent de détecter les vides et de regrouper les micro-opérations en un unique bloc. Il faut signaler que certains processeurs arrivent à se passer de cette étape de compactage, mais au prix de ''scheduler'' nettement plus complexes. Autre problème : quand il faut choisir quelle instruction émettre, il y a toujours plusieurs candidats. Si on choisit mal, des instructions restent en attente trop longtemps parce que d'autres instructions plus jeunes leur passent devant. Pour éviter cela, les micro-opérations les plus vielles, les plus anciennes, sont prioritaires. Pour cela, on peut utiliser une FIFO un peu spéciale pour le ''scheduler''. Si les ajouts d'instruction se font dans l'ordre, les micro-opérations ne quittent pas forcément le ''scheduler'' dans l'ordre imposé par une FIFO : les micro-opérations restent triées dans leur ordre d'ajout, même s'il y a des vides entre elles. Dans ces conditions, il est préférable que le compactage conserve l'ordre FIFO des instructions. Dans ces conditions, la micro-opération la plus ancienne est celle qui est située à l'adresse la plus faible : le circuit de sélection peut donc être fabriqué avec des encodeurs, et est relativement simple. ==Les ''schedulers'' basées sur une matrice de dépendances== L'implémentation la plus simple étend le fonctionnement d'un pipeline dynamique usuel. Rappelez-vous le chapitre sur les pipelines dynamiques. Nous avions vu que de tels pipelines émettaient une instruction par cycle, quitte à émettre des bulles de pipeline en cas de dépendance bloquante. Et il est possible d'améliorer leur unité d'émission pour qu'elle soit compatible avec l'exécution dans le désordre. ===Rappels sur le registre de réservation/disponibilité=== L'émission d'une instruction est gouvernée par un ''registre de réservation'' qui indique quels registres sont réservés par une instruction, et ceux qui sont libres. Un registre réservé est un registre qui sera écrit par une instruction en cours d'exécution, mais qui n'a pas encore écrit son résultat. Les instructions candidates doivent avoir leurs opérandes dans des registres libres. Sinon, c'est signe que leurs opérandes sont réservés en écriture, donc pas encore écrites, donc pas disponibles. Si une instruction candidate veut lire ce registre, c'est signe qu'il y a une dépendance RAW : elle veut lire un registre réservé, qui est destiné à être écrit par une instruction en vol. Si elle veut écrire, c'est signe qu'elle veut écrire dans un registre où une autre instruction veut écrire : c'est une dépendance WAW. Dans les deux cas, la micro-opération n'est pas émise. Le registre de réservation encode les registres réservés comme suit : chaque bit est associé à un registre. Le bit numéro 0 est associé au registre numéro 0, le bit numéro 1 au registre numéro 1, etc. Là, En clair, les numéros de registres réservés sont encodés en représentation non pas binaire, mais en représentation ''one-hot'' (vue au premier chapitre). {|class="wikitable" |+ Registre de réservation |- ! Registre 7 !! Registre 6 !! Registre 5 !! Registre 4 !! Registre 3 !! Registre 2 !! Registre 1 !! Registre 0 |- | 0 || 0 || 1 || 0 || 0 || 1 || 0 || 0 |} Avant d'émettre une instruction, l'unité d'émission extrait les registres opérande/destination et les convertis dans la même représentation que le registre de réservation, à savoir en représentation ''one-hot''. Le circuit de traduction binaire vers ''one-hot'' est, pour rappel, un simple décodeur. Puis, on fait un OU logique entre les sorties des décodeurs. Le résultat est un masque qui indique quels registres sont lus par la micro-opération, encodé en représentation ''one-hot''. Appelons-le le '''masque d'opérandes'''. Le masque est alors comparé au registre de réservation pour vérifier si la micro-opération peut être émise. [[File:Unité d'émission simple, dans l'ordre.png|centre|vignette|upright=2|Unité d'émission simple, dans l'ordre]] Si l'émission est autorisée, le registre de réservation est là aussi mis à jour. Le registre de réservation est aussi mis à jour dès qu'une instruction enregistre son résultat dans les registres, ou alors dès qu'il est disponible pour le contournement. Le bit associé au registre repasse alors à 0 (ou à 1). Sans contournement, la mise à jour se fait alors à la toute fin de la micro-opération, quand elle se termine, lors de la dernière étape d'enregistrement, à la fin de son pipeline. Avec, elle se fait quand la micro-opération quitte l'unité de calcul. ===L'extension à l'exécution dans le désordre : l'éveil par matrice de bits=== La différence entre un pipeline dynamique et l'exécution dans le désordre est que l'on passe d'une instruction à émettre à plusieurs. Plusieurs instructions sont mises en attente dans un ''scheduler'', et y attendent leur tour. L'idée est alors d'envoyer le registre de disponibilité à toutes les entrées, à toutes les micro-opérations en attente. Ainsi, on sait quelles sont les micro-opérations qui peuvent s'exécuter et celles qui ne le peuvent pas. L'implémentation est assez simple, elle ressemble beaucoup à l'implémentation d'un pipeline dynamique simple, si ce n'est que des circuits sont dupliqués. L'implémentation utilise des ''scheduler'' légèrement différentes de celles introduites plus haut. Elles n'utilisent notamment pas de bits de disponibilité, mais autre chose. Lorsqu'une instruction est ajoutée à le ''scheduler'', le masque de disponibilité des opérandes est stocké dans le ''scheduler''. A chaque cycle, le registre de disponibilité est envoyé à toutes les entrées, et est comparé avec tous les masques de disponibilité des opérandes. Si une comparaison renvoie un 1, alors la micro-opération de l'entrée est une instruction candidate à l'émission. [[File:Unité d'émission dans le désordre basée sur un registre de disponibilité.png|centre|vignette|upright=2.5|Unité d'émission dans le désordre basée sur un registre de disponibilité]] L'ensemble est implémenté avec l'aide d'une '''matrice de bits''', où chaque ligne correspond à une entrée et chaque colonne à un registre. À chaque croisement entre une ligne et une colonne, on trouve un bit. Si le bit de la ligne n et de la colonne m est à 1, cela veut dire : la micro-opération dans l'entrée n a besoin de lire le registre m. S’il est à 0, alors cela veut dire que la micro-opération stockée dans la ligne n'a pas besoin de la donnée dans le registre m. [[File:Planificateur à matrice de bits.jpg|centre|vignette|upright=2|Planificateur à matrice de bits.]] Lorsqu'une instruction réserve une entrée, elle initialise la ligne en fonction des registres qu'elle souhaite lire. Pour vérifier si une instruction a ses opérandes prêts, le processeur compare le registre de disponibilité à la ligne associée à la micro-opération/entrée. À chaque intersection ligne-colonne, se trouve un comparateur de 1 bit, qui détecte si le registre est demandé et disponible. Les résultats de cette porte sont ensuite envoyés à une porte ET, qui fait le gros du travail. [[File:Logique de détection de la disponibilité d'une instruction.jpg|centre|vignette|upright=2|Logique de détection de la disponibilité d'une instruction.]] ==Les ''schedulers'' basées sur une mémoire associative== Les processeurs modernes utilisent une mémoire associative pour le ''scheduler''. Les mémoires associatives ont été abordées il y a quelques chapitres, mais faisons quelques rappels. Les mémoires associatives servent à accélérer la recherche de données dans un ensemble. Or, un ''scheduler'' vérifie régulièrement si chaque entrée est prête. Il s'agit d'un processus de recherche d'une instruction qui respecte une condition bien précise dans la mémoire, ce qui fait que les mémoires associatives sont donc tout indiquées. Comme vous le savez, les signaux de commande d'une instruction sont propagés avec celle-ci dans le pipeline, le nom du registre de destination ne faisant pas exception. Le nom de registre est envoyé à le ''scheduler'' lors de l'écriture du résultat dans les registres. S'il y a correspondance, l'opérande est disponible et son bit de disponibilité est mis à 1. On peut adapter cette méthode pour tenir compte du contournement assez simplement. [[File:Détection des dépendances par propagation du registre de destination.png|centre|vignette|upright=2|Détection des dépendances par propagation du registre de destination.]] En conséquence, le circuit de détection des dépendances est constitué d'un grand nombre de comparateurs : un par champ « nom de registre » dans chaque entrée. La logique d'éveil/''wake up'' regroupe tous les comparateurs, la logique de sélection est un circuit situé en dehors de la mémoire associative. Les entrées sont dans la mémoire associative elle-même. [[File:Gestion des bits de validité.png|centre|vignette|upright=2|Gestion des bits de validité.]] L'implémentation la plus simple est celle du schéma précédent, où on utilise une mémoire associative unique. Une version plus élaborée sépare le ''scheduler'' en deux parties : une mémoire qui mémorise les registres des opérandes, une autre mémoire pour le reste. La mémoire pour les registres opérandes est la mémoire associative proprement dite, c'est elle qui est associée aux comparateurs, à la logique de ''wake-up''/réveil, etc. Par contre, l'autre mémoire est une mémoire RAM simplifiée, qui mémorise les informations qui n'ont pas besoin des comparateurs. L'opcode de la micro-opération est là-dedans, par exemple. Le ''scheduler'' est donc une sorte de mémoire associative améliorée. Il se trouve que c'est aussi le cas du ROB. Et il est possible de fusionner le tampon de ré-ordonnancement avec le ''scheduler'' dans une structure unique. La différence avec un ''scheduler'' normal est que les entrées sont libérées non pas quand la micro-opération est exécutée/émise, mais quand elle termine son exécution et quitte le ROB. le ''scheduler'' incorpore des bits dans chaque entrée, pour savoir si la µops associée a été émise et est en cours de traitement dans les ALUs. La méthode a notamment été utilisée sur les processeurs d'architecture K6 d'AMD. Le tout donnait une structure appelée le '''DRIS''' (''deferred scheduling register renaming instruction shelf''). Les processeurs PA 8000 de HP étaient aussi vraisemblabment dans ce cas, aussi. ===Les optimisations de la consommation d'énergie : la désactivation des portions inutilisées=== Le problème avec des ''scheduler'' de ce type est leur forte consommation énergétique, ainsi que le grand nombre de portes logiques utilisées, qui deviennent prohibitifs pour des ''scheduler'' un peu grosses. Pour résoudre ce problème, certains ont optimisé les comparateurs. D'autres ont tenté de profiter du fait que la majorité des bits dans les entrées sont composés de zéros. Bref, les optimisations purement matérielles sont légion. Une première solution est de désactiver les entrées qui sont inutilisées, vides, sans instruction. Pour cela, on fait précéder les comparateurs par un circuit qui les connecte ou déconnecte des lignes de bit. Le circuit est commandé par le bit ''empty'' qui indique si une entrée est vide ou non. L'avantage est que la consommation d'énergie de le ''scheduler'' devient alors proportionnelle au nombre d'entrées non-vides, et non au nombre d'entrées total. Du moins, en apparence, les entrées non-utilisées doivent quand même être alimentées pour fonctionner, mais l'activité de comparaison disparait dans les entrées vides. Il est possible d'étendre cette technique aux entrées dont les opérandes sont prêts. Les gains sont généralement assez bons, avec une réduction de consommation variant entre 30 et 50%, avec une implémentation très simple et très économe en circuits. Une autre solution, assez simple à mettre en place, consiste à désactiver une partie de le ''scheduler'' sin elle est inutilisée. Pour cela, il faut segmenter le ''scheduler'' avec la technique du ''wire partitionning''. Pour rappel, un ''scheduler'' est une mémoire associative. Et comme toute mémoire associative, elle contient des fils, les lignes de bit, qui relient les entrées/sorties à toutes les cellules mémoire. Plus une ligne de bit est longue, plus elle consomme d'énergie et plus le temps de lecture/écriture est long. L'idée est de segmenter les lignes de bits en plaçant des répéteurs qui recopient la tension d'un segment au suivant. Les répéteurs sont des tampons trois-états, ce qui permet de déconnecter les portions inutilisées de la ligne de bit. Ce faisant, le ''scheduler'' est découpée en segments, qui peuvent être désactivés si besoin. S'il y a peu d'instructions chargées dans le ''scheduler'', les portions inutilisées sont désactivées. La technique marche d'autan mieux si les micro-opérations sont compactées au début de le ''scheduler''. [[File:Wire partitionning.png|centre|vignette|upright=2|Wire partitionning]] Tout le problème est de savoir quelles portions désactiver. La technique est assez simple si le ''scheduler'' est compactée, à savoir si toutes les micro-opérations sont régulièrement regroupées au début de la mémoire CAM. Mais même avec cette optimisation, la décision de désactiver une portion de le ''scheduler'' n'est pas à prendre à la légère. Il ne faut pas désactiver une portion contenant des instructions pouvant être émise sous peu. La décision dépend de paramètres variés : proportion d'entrée vides, quantité d'entrées récemment activées/désactivées récemment, présences d'entrées avec un ''match'' d'opérandes récent, etc. Il est important de préciser que les techniques en question fonctionnent aussi sur les autres types de ''scheduler'', qui ne sont pas basées sur des mémoires associatives. Nous allons voir celles-ci dans la suite du chapitre, mais gardez à l'esprit que ces optimisations, à savoir désactiver les entrées vides/prêts et partitionner le ''scheduler'', sont des optimisations générales qui marchent avec presque tout. ===L'ajout d'une FIFO pour les micro-opérations déjà prêtes à l'émission=== D'autres techniques permettent de réduire aussi bien le cout en circuits que la consommation d'énergie de le ''scheduler''. L'idée est d'utiliser une mémoire associative quand elle est réellement nécessaire, mais d'utiliser un ''scheduler'' "normale" dès que possible. La méthode la plus simple tient compte du fait que certaines µops ont déjà tous leurs opérandes de disponibles à l'émission, mais doivent quand même être mises en attente, parce que l'ALU adéquate est occupée, parce que des dépendances WAW/WAR sont encore là, ou pour tout autre raison. Dans ce cas, elles sont mises en attente non pas dans le ''scheduler'', mais dans une mémoire FIFO toute simple. L'idée est qu'au lieu d'avoir une énorme mémoire associative pour le ''scheduler'', on déplace quelques entrées dans une petite FIFO annexe. Le résultat est un gain d'énergie et de circuits, au prix d'une perte de performance mineure. La perte de performance en question se manifeste si aucune instruction n'a ses opérandes de prêtes à l'émission : le programme n'utilise pas la FIFO et voit alors un ''scheduler'' plus petite. Une amélioration de cette technique tient compte du fait que certaines instructions sont dans un cas intermédiaire. Elles ont déjà un opérande de prête lors de l'émission, mais pas l'autre. Le nombre d'opérandes varie suivant l’instruction : certaines n'ont besoin que d'un seul opérande. De plus, il arrive qu'une opération tout juste décodée ait déjà un ou plusieurs de ses opérandes de prêts. Cette constatation permet d'éviter d'utiliser des comparateurs pour les opérandes déjà prêts. Par exemple, on peut parfaitement utiliser trois ''scheduler'' : * une pour les micro-opérations dont tous les opérandes sont prêts, mais qui sont quand même mises en attente, sans comparateur ; * une pour les micro-opérations dont un seul opérande manque, qui n'utilise qu'un comparateur par entrée ; * une pour les micro-opérations dont deux opérandes manquent à l'appel, qui utilise deux comparateurs par entrée. C'est beaucoup plus économique que d'utiliser une seule grosse ''scheduler'' qui contiendrait autant d'entrées que les trois précédentes réunies, avec deux comparateurs par entrée. Certains sont même allés plus loin, et ont proposé de supprimer le ''scheduler'' avec deux opérandes par entrée. Les micro-opérations dont deux opérandes sont inconnus au décodage sont stockées dans le ''scheduler'' pour instructions avec un comparateur par entrée. Un circuit de prédiction se charge alors de prédire l'opérande manquant, cette prédiction étant vérifiée plus loin dans le pipeline. Il serait cependant étonnant qu'une telle proposition ait donné lieu à la moindre implémentation réelle. ===Les optimisations de la consommation d'énergie avancées=== D'autres chercheurs ont conservé un ''scheduler'' unique, avec autant de comparateurs par entrée qu'il y a d'opérandes possibles par instruction. Simplement, le processus de détection des opérandes prêts est légèrement ralenti : on ne vérifie qu'un opérande par cycle. Pour vérifier les deux opérandes d'une entrée, on doit attendre deux cycles. Enfin, certains chercheurs ont proposé des ''scheduler'' segmentées. Les micro-opérations circulent à chaque cycle d'un segment vers le suivant : en conséquence, certains segments conservent les micro-opérations les plus anciennes, un autre les micro-opérations les plus jeunes, etc. Seul le denier segment, celui qui contient les micro-opérations les plus vielles, peut émettre une instruction : la détection des opérandes se fait seulement dans le dernier segment, qui contient les micro-opérations les plus anciennes. On économise ainsi beaucoup de comparateurs. Certains chercheurs ont tenté de pipeliner l'étape de sélection des opérandes, ainsi que l'étage d'arbitrage. Mais en faisant cela, il faut plusieurs cycles pour détecter qu'une instruction a ses opérandes prêts, ce qui pose problème face à de nombreuses dépendances RAW. Pour éviter de trop perdre en performances, certains chercheurs ont décidé d'utiliser des techniques pour prédire quelles seront les micro-opérations dont les opérandes seront bientôt prêts. Si détecter qu'une instruction est prête prend n cycles, le processeur devra tenter de prédire la future disponibilité des opérandes n cycles en avance pour obtenir des performances optimales. ===Remplacer la mémoire associative par une RAM=== Une autre optimisation possible est de remplacer le ''scheduler'' par une mémoire RAM, dont chaque mot mémoire correspondrait à une entrée. Une telle optimisation permet en théorie de se passer des comparateurs associés à chaque entrée, mais au prix de l'ajout de circuits annexes potentiellement couteux. La première de ces techniques, la '''recherche directe par étiquette''' (direct tag search) fut créée par Weiss et son collègue Smith. Ils cherchaient à améliorer l'algorithme de Tomasulo, un algorithme qu'on expliquera dans quelques chapitres. Rappelons que chaque instruction produit un résultat, qui devra être rapatrié dans une ou plusieurs entrées de le ''scheduler''. L'optimisation de la recherche directe par étiquette fonctionne dans le cas où le résultat de la micro-opération n'est utilisé que par une seule entrée, et pas plusieurs. Le principe est simple : chaque champ « opérande » d'une entrée est adressable via le nom de registre de cet opérande. Pour faire le lien entre entrée et nom de registre, la recherche directe par étiquette ajoute une table de correspondances matérielle, une mémoire RAM qui mémorise les adresses des champs « opérande » des entrées. Les adresses envoyées dans cette mémoire sont les noms de registres des résultats. Lors de l'émission, la table de correspondances est mise à jour. Les numéros des champs « opérande » réservés lors de l'émission sont mémorisés dans les mots mémoire qui correspondent aux deux registres sources. Toutefois, une petite vérification est faite lors de l'émission : s'il y a déjà une correspondance dans la table pour un registre source, alors une autre instruction compte lire ce registre. Pour éviter d'écraser les données de la micro-opération précédente dans la table de correspondances, l'émission de la micro-opération est bloquée. [[File:Recherche directe par étiquette.png|centre|vignette|upright=2|Recherche directe par étiquette.]] ==Les ''schedulers'' avec préplanification== Avec la '''préplanification''' (''prescheduling''), le ''scheduler'' est composée de mémoires FIFO dans lesquelles les micro-opérations sont triées dans l'ordre d'émission. Il n'y a pas de logique d’émission proprement dite, celle-ci se bornant à vérifier si la micro-opération située au début de la FIFO peut s’exécuter. Par contre, le préplanificateur détecte les dépendances et en déduit où insérer les micro-opérations dans le tampon d'émission, à l'endroit le plus adéquat. [[File:Préplanification.jpg|centre|vignette|upright=2|Préplanification.]] ===L'usage de FIFO multiples=== La technique de préplanification la plus simple utilise plusieurs FIFO. Quand une instruction a une dépendance avec une autre instruction, les deux sont placées dans la même FIFO. Pour cela, le préplanificateur détecte les chaines d'instructions dépendantes, et place les micro-opérations dans la FIFO adéquate. A chaque cycle, l'unité d'émission lit une micro-opération dans chaque FIFO et vérifie si elle peut l'émettre. Si la micro-opération n'a pas ses opérandes disponibles, ou qu'une dépendance structurelle survient, alors la FIFO est bloquée. La micro-opération attend que la dépendance soit résolue, bloquant toutes les micro-opérations précédentes. Mais les autres FIFO ne sont pas bloquées, laissant de la marge de manœuvre. [[File:Préplanification par FIFO multiples.png|centre|vignette|upright=1.5|Préplanification par FIFO multiples.]] ===La technique du tampon trié=== La technique du '''tampon trié''' trie les micro-opérations selon le temps d'attente avant leur exécution. Les micro-opérations qui sont censées s’exécuter bientôt sont insérées au tout début de la FIFO, tandis que les micro-opérations qui s’exécuteront dans un long moment sont insérées vers la fin. Là encore, l'unité d'émission lit une micro-opération dans la FIFO, et détermine si elle peut être émise. Si une dépendance survient, l'émission est retardée, et la FIFO est bloquée. [[File:Préplanification par tampon trié.png|centre|vignette|upright=1.5|Préplanification par tampon trié.]] La technique a cependant quelques problèmes assez importants. Le premier problème est que la FIFO est bloquée lorsqu'une micro-opération ne peut être émise. Le second problème est que le temps avant exécution n'est pas toujours connu, notamment pour les micro-opérations d'accès mémoire. Et cela se répercute sur les micro-opérations dépendantes de celles-ci. Pour résoudre ce genre de problèmes, l'usage d'un pipeline à ''replay'' est possible, et est même l'une des meilleures solutions possible, malgré ses défauts. L'idée est que les lectures sont pré-planifiées en supposant qu'elles font un succès de cache L1. Si la prédiction est fausse, la lecture est ré-exécutée plusieurs cycles plus tard, en supposant qu'elle fait un succès dans le cache L2, et ainsi de suite. Elle est alors ré-insérée dans la FIFO, elle est pré-planifiée une seconde fois. Il en est de même si une micro-opération ne peut pas être émise : il suffit de la réinsérer dans la FIFO au bon endroit, en attendant que ses dépendances soient résolues. Ainsi, la micro-opération fautive ne bloque pas le ''scheduler'', et retente sa chance autant de fois qu'il le faut. [[File:Préplanification par fenêtre d’instruction scorebarodée.png|centre|vignette|upright=1.5|Préplanification par ''scheduler'' scorebarodé.]] Une autre solution utilise deux ''schedulers'' : une FIFO gérée par la préplanification, et une vraie ''scheduler'' pour les micro-opérations dont le temps d'attente ne peut pas être déterminé. Il est possible de mettre cette dernière avant la préplanification, afin de gérer les temps d'attente des instructions d'accès mémoire. Quand le temps d'attente d'une lecture devient connu, ses instructions dépendantes sont gérées avec préplanification. [[File:Préplanification avec fenêtre d’instruction.png|centre|vignette|upright=2.5|Préplanification avec ''scheduler''.]] ===Les ''scheduler'' avec allocation temporelle statique=== L''''allocation temporelle statique''' correspond à une technique utilisée sur les processeurs Cuzco de l'entreprise Condor. La technique en question a été présenté en 2025, mais des idées similaires ont été présentées dans la littérature académique. Elle ressemble beaucoup aux techniques de pré-planification, avec cependant quelques différences. L'idée est simple : le processeur dispose de plusieurs ''scheduler'', qui mettent en attente les micro-opérations. Lors de l'étape de renommage de registres, le processeur détermine dans combien de cycles d'horloge la micro-opération sera prête pour exécution. La micro-opération est alors mise en attente durant ce nombre de cycles, dans les ''scheduler'', puis est émise une fois ce nombre de cycles écoulés. Ce faisant, les ''scheduler'' n'ont pas besoin de détecter la disponibilité des opérandes à chaque cycle, pour chaque micro-opération en attente. Le ''timing'' de l'émission des micro-opérations est décidé à l'avance. L'implémentation exacte n'est pas connue, mais plusieurs solutions sont imaginables. Par exemple, le ''scheduler'' peut simplement être composée de plusieurs mémoires de petite taille, chacune contenant les micro-opérations destinées à être exécutées dans N cycles, N étant différent pour chaque mémoire. Une autre solution, plus réaliste, utilise un ''scheduler'' dans laquelle la micro-opération est couplée à des champs pour les opérandes, et un champ compteur. Le champ compteur est initialisé avec le nombre de cycles à attendre, et est décrémenté à chaque cycle d'horloge. Quand il atteint zéro, la micro-opération est émise. Vous remarquerez que la méthode ne marche que si toutes les micro-opérations prennent un nombre fixe de cycles d'horloge. Et autant c'est le cas pour les micro-opérations arithmétiques ou logiques, autant les accès mémoire ne rentrent pas dans ce cadre. En théorie, on ne sait pas combien de temps prendra un accès mémoire. Et cette incertitude se répercute sur les micro-opérations dépendantes d'un accès mémoire. Pour éviter cela, le processeur réutilise un pipeline à ''replay'' tel que vu dans le chapitre précédent. L'unité de renommage de registre suppose que tout accès mémoire fait un succès de cache L1, et décide des ''timings'' d'émission sous cette hypothèse. Si une lecture subit un défaut de cache, elle est ré-exécutée, de même que toutes les micro-opérations dépendantes. Pour cela, le registre de destination de la lecture est marqué comme invalide, grâce à un bit spécifique attaché au registre. Toute instruction qui lit un opérande invalide est elle aussi ré-executée de zéro. Pour simplifier, l'unité d'émission ressemble à une unité d'émission dans l'ordre, avec un registre de disponibilité, qu'on aurait amélioré pour aller au-delà de la simple détection des dépendances d'instruction. Pour déterminer les ''timings'' d'émission, à savoir quand émettre une micro-opération, le processeur utilise une unité d'émission à deux étages. Le premier étage est appelé le ''register scoreboard'', le second étage est appelé la ''Time Ressource Matrix''. Les deux étages communiquent entre eux, comme on va le voir, et leurs noms donnent une idée de ce qu'ils font. Le premier étage gère les dépendances de registres, le second gère les dépendances structurelles. Le premier étage est un registre de disponibilité amélioré, qui gère les dépendances de registre. Cet étage sait quand tel registre sera écrit, et donc que l'opérande écrit dedans sera disponible à partir de tel cycle d'horloge. Il le sait, car l'étage suivant l'aura prévenu, mais laissons cela de côté pour le moment. Quand une micro-opération rentre dans cet étage, il vérifie les dépendances avec les registres et les micro-opérations antérieures. Vu qu'il sait quand les registres seront disponibles, il peut déterminer quand la micro-opération pourra s'exécuter. Par exemple, si une micro-opération lit les deux registres R7 et R15, qui sont disponibles respectivement dans 5 et 7 cycles, le premier étage sait que la micro-opération devra attendre 7 cycles. Mais tout cela ne suffit pas à déterminer quand émettre une micro-opération. En effet, il fait aussi gérer les dépendances structurelles, ce qui est le boulot du second étage. Le second étage utilise pour cela une ''Time Ressource Matrix'', qui mémorise l'occupation de diverses "ressources", pour les 256 cycles d'horloge à venir. Les ressources en question sont les ports de lecture/écriture du banc de registre, les ALUs utilisées, si la micro-opération utilise l'unité mémoire, etc. Le second étage prend en entrée une micro-opération, détermine quelles "ressources" elle utilise, puis regarde leur disponibilité. Elle détecte alors les dépendances structurelles et détermine alors quand peut s'exécuter la micro-opération. Elle peut retarder l'émission de quelques cycles, si une dépendance structurelle est détectée. Une fois sortie du second étage, on sait quand la micro-opération va être émise, à quel cycle précisément. Et vu que la durée de la micro-opération est fixe, on sait quand cette micro-opération va se terminer, quand son résultat sera disponible. Cela permet de mettre à jour la ''Time Ressource Matrix'', pour préciser que le port d'écriture sera occupé à tel moment. De plus, cette information est transmise au premier étage, qui sait alors quand est enregistré le résultat dans le registre de destination. C'est comme cela que le premier étage sait quand un registre est disponible : le second étage le prévient, quand il émet une instruction ! La ''Time Ressource Matrix''mémorise l'occupation des "ressources" pour les 256 cycles d'horloge à venir. Cependant, elle ne vérifie pas les dépendances pour les 256 cycles suivants. Quand elle reçoit une micro-opération, elle teste les dépendances structurelles pour seulement les 8 prochains cycles maximum. Si la micro-opération ne peut pas être émise pendant ces 8 cycles, le pipeline est bloqué, un ''pipeline stall'' est émis. Pour être plus précis, vu que le processeur peut décoder 8 micro-opérations en même temps, cela permet de ne consulter que 64 cycles d'horloge sur les 256. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les premiers processeurs Intel | prevText=Les premiers processeurs Intel | next=Le renommage de registres | nextText=Le renommage de registres }} </noinclude> a6ai28jcg8k5dth4u7hi1uahphhskdv Fonctionnement d'un ordinateur/Le parallélisme mémoire 0 65888 774147 773848 2026-10-04T14:56:45Z Mewtow 31375 774147 wikitext text/x-wiki Dans ce chapitre, nous allons voir les techniques de '''parallélisme mémoire''', qui permettent de gérer plusieurs accès mémoire simultanés, que ce soit au niveau du cache ou de la mémoire RAM. Par plusieurs accès mémoire simultanés, vous pensez sans doute à l'usage de cache multiports, voire de mémoires RAM multiport. C'est en effet une solution, mais ce n'est clairement pas la seule. Par exemple, il est possible de pipeliner l'accès au cache et/ou l'accès à la mémoire, et nous verrons comment faire dans ce chapitre. Pipeliner l'accès au cache est une technique en vigueur dans les processeurs modernes, qu'ils aient ou non un pipeline dynamique. Par contre, pipeliner la mémoire est une technique ancienne, aujourd'hui peu utilisée. Elle était utilisée sur des très vieux ordinateurs, qui avaient un pipeline mais pas de mémoire cache. De nos jours, les mémoires SDRAM et DDR ne sont pas adaptées pour ça, il est très difficile de les pipeliner correctement. Le parallélisme mémoire est utile aussi bien sur des processeurs à émission dans l'ordre que dans le désordre. Par exemple, un processeur ''in-order'' avec des lectures non-bloquantes peut en profiter. Il peut techniquement lancer une seconde lecture, après avoir rencontré une première lecture non-bloquante. En général, le nombre de lectures consécutives est cependant limité à moins d'une dizaine. À l'opposé, les processeurs sans lectures non-bloquantes profitent pas du parallélisme mémoire. À la rigueur, ils peuvent en profiter s'ils intègrent des techniques de préchargement. Pendant que le processeur exécute une lecture/écriture, il peut précharger une autre donnée en parallèle. Inutile de dire que sans pipeline processeur, le parallélisme mémoire ne sert pas à grand-chose. ==Le ''line fill buffer'' et les techniques associées== Lors d'un défaut de cache, le processeur doit attendre que toute la ligne de cache soit chargée avant d'être utilisable. Or, la taille d'une ligne de cache est supérieure à la largeur du bus mémoire, ce qui fait qu'une ligne de cache est chargée en plusieurs fois, morceaux par morceaux, mot mémoire par mot mémoire. Le chargement peut se faire directement dans le cache, mais ce n'est pas une solution très pratique. À la place, beaucoup de processeurs ajoutent une mémoire tampon entre la RAM et le cache, appelée le '''tampon de remplissage de ligne''' (''line-fill buffer'') dans les processeurs modernes. Lors d'un défaut de cache, le processeur charge la donnée de la RAM dans le tampon de remplissage de ligne, mot mémoire par mot mémoire. Une fois plein, le tampon de remplissage de ligne est recopié dans la ligne de cache. Le tampon de remplissage de ligne contient une ligne de cache, si ce n'est que certains bits de contrôle ne sont pas présents. Le tampon de remplissage de ligne contient un bit de validité pour chaque mot mémoire de la ligne de cache, qui indiquent si le mot mémoire a été chargé. Par exemple, prenons un processeur 64 bits, qui gère donc des mots mémoire de 8 octets, avec des lignes de cache 256 octets/32 mots mémoire. Le tampon de remplissage de ligne contiendra 32 bits de validité. Si le processeur a chargé les 6 premiers mots mémoire, les 6 premiers bits de validité seront mis à 1, les autres seront encore à zéro. Il faut noter que la disponibilité de la ligne complète se détermine assez facilement en faisant un ET logique entre tous les bits de validité. Le processeur sait ainsi quand la ligne de cache est disponible entièrement et donc quand la transférer dans le cache. La même chose existe avec une hiérarchie de cache, sauf que l'on trouve une mémoire tampon entre chaque niveau de cache. Il y a un tampon de remplissage de ligne entre le cache L1 et le cache L2, entre le cache L2 et le L3, etc. Si le cache ne gère qu'un seul défaut de cache à la fois, le ''fill line buffer'' est une mémoire très simple, qui ne mémorise qu'une seule ligne de cache. Et cela vaut aussi bien pour un cache bloquant que non-bloquant. Mais sur les caches capables de gérer plusieurs défauts simultanés, le tampon de remplissage de ligne est une mémoire de type FIFO ou LIFO, capable de mémoriser plusieurs lignes de cache. Notons que le tampon de remplissage de ligne est très utile pour implémenter certaines techniques, par exemple le contournement du cache. Nous avions vu dans le chapitre sur le cache que certains accès mémoire doivent contourner le cache, pour des raisons de cohérence des caches. C'est notamment nécessaire pour accéder aux périphériques, mais c'est aussi utile pour des raisons de performances dans des cas très spécifiques. Les accès qui contournent le cache se font directement dans le tampon de remplissage de ligne : le processeur écrit ou lit les données depuis ce tampon de remplissage de ligne, sans accéder au cache. ===L'''early restart'' et le ''critical word load''=== La présence du ''line buffer'' permet une optimisation assez intéressante, qui permet de réduire la latence des défauts de cache. L'optimisation consiste à lire un mot mémoire dans le tampon de remplissage de ligne, même si la ligne de cache complète n'a pas encore été chargée. Il existe deux manières de faire cela, qui portent les noms d'''early restart'' et de ''critical word load''. La première est la version la plus simple, la seconde est plus complexe mais plus performante. Avec la technique d''''''early restart''''', la ligne de cache est chargée normalement, en partant de son premier mot mémoire. Dès que le mot mémoire lu/écrit par le processeur est copié dans le ''line fill buffer'', il est envoyé au processeur immédiatement. Illustrons le tout par un exemple, où une ligne de cache fait 16 mots mémoire. Le processeur effectue une lecture, qui lit le 5ème mot mémoire. Sans ''early restart'', le processeur doit charger les 16 mots mémoire avant de faire la lecture dans le cache. Avec ''early restart'', le processeur reçoit la donnée dès que le 5ème mot mémoire est disponible. Le processeur doit attendre que les 4 premiers mots mémoire soient chargés, puis le 5ème mot mémoire arrive et est envoyé directement au processeur, il est lu directement depuis le ''line fill buffer''. Les 11 mots mémoire suivants sont ensuite chargés dans le cache pendant que le processeur fait des calculs dans son coin. Le ''critical word load'' est une optimisation de la technique précédente où le chargement de la ligne de cache commence directement à la donnée demandée par le processeur. Pour reprendre l'exemple précédent, où le processeur demande le 5ème mot mémoire sur 16, le ''critical word load'' charge le 5ème mot mémoire en premier et l'envoie au processeur, ce qui fait qu'il est chargé très rapidement. Pas besoin d'attendre que le processeur charge les 4 mots mémoire précédents comme avec l'''early restart''. Dans le détail, le processeur charge le 5ème mot mémoire en premier, puis charge les 11 suivants, et termine par les 4 mots mémoire du début. En clair, le ''critical word load'' commence par charger le mot mémoire lu, puis les blocs suivants, avant de revenir au début du bloc pour charger les blocs restants. Ainsi, la donnée demandée par le processeur sera la première disponible. Pour cela, l'organisation du tampon de remplissage de ligne est modifiée de manière à rendre cela possible. Il a une taille égale à une ligne de cache complète, qui contient elle-même plusieurs mots mémoire. Dans le ''line-fill buffer'', chaque mot mémoire est stocké avec un tag, qui indique l'adresse du mot mémoire stocké dans le ''line-fill buffer''. Le ''line-fill buffer'' est donc un cache un peu particulier, qui fonctionne comme un cache du point de vue du processeur, comme une mémoire FIFO pour les transferts avec le cache. Ainsi, un processeur qui veut lire dans le cache après un défaut peut accéder à la donnée directement depuis le tampon de remplissage de ligne, alors que la ligne de cache n'a pas encore été totalement recopiée en mémoire. ==Les caches pipelinés== Il est possible de pipeliner l'accès au cache, ce qui demande juste de rajouter des registres au bon endroits et quelques circuits de contrôle. Pipeliner l'accès au cache est une technique en vigueur dans les processeurs modernes, même ceux avec un pipeline dynamique. Et l'implémentation peut se faire de plusieurs manières différentes. Il faut dire que découper une mémoire cache en plusieurs étapes peut se faire de plusieurs manière. Commençons par la plus simple. Beaucoup de processeurs des années 2000 avaient une fréquence peu élevée comparée aux standards d'aujourd'hui, ce qui fait que leur cache avait bien un temps d'accès d'un cycle d'horloge. Mais l'accès au cache se faisait en deux cycles d'horloge : un cycle pour calculer une adresse et un cycle pour l'accès mémoire proprement dit. Et ces deux étapes étaient pipelinées, à savoir que deux micro-opérations mémoire peuvent s'exécuter en même temps : une dans l'étage de calcul d'adresse, une autre dans le cache. L'unité mémoire était donc pipelinée, alors que l'accès au cache ne l'était pas. Les processeurs qui implémentaient cette technique regroupent les micro-architectures K5 et K6 d'AMD, les processeurs Intel de micro-architecture P6 (Pentium 2 et 3) et quelques autres. Il est aussi possible de pipeliner l'accès au cache lui-même. Avec un cache pipeliné, l'accès au cache ne se fait pas en un seul cycle, mais en plusieurs. Cependant, on peut lancer un nouvel accès au cache à chaque cycle d'horloge, comme un processeur pipeliné lance une nouvelle instruction à chaque cycle. L'implémentation est assez simple : il suffit d'ajouter des registres dans le cache. Pour cela, on profite que le cache est composé de plusieurs composants séparés, qui échangent des données dans un ordre bien précis, d'un composant à un autre. De plus, le trajet des informations dans un cache est linéaire, ce qui les rend parfois pour l'usage d'un pipeline. ===Le ''pipelining'' des caches ''direct-mapped''=== Voici ce qui se passe avec un cache directement adressé. Pour rappel ce genre de cache est conçu en combinant une mémoire RAM, généralement une SRAM, avec quelques circuits de comparaison et un MUX. L'accès au cache se fait globalement en deux étapes : la première lit la donnée et le ''tag'' dans la SRAM, et on utilise les deux dans une seconde étape. Nous avions vu il y a quelques chapitre comment pipeliner des mémoires, dans le chapitre sur les mémoires évoluées. Et bien ces méthodes peuvent s'utiliser pour la mémoire RAM interne au cache !L'idée est d'insérer un registre entre la sortie de la RAM et la suite du cache, pour en faire un cache pipéliné, à deux étages. Le premier étage lit dans la SRAM, le second fait le reste. L'implémentation sur les caches associatifs à plusieurs voies est globalement la même à quelques détails près. [[File:Cache directement adressé pipeliné - deux étages.png|centre|vignette|upright=2|Cache directement adressé pipeliné - deux étages]] Avec le circuit précédent, il est possible d'aller plus loin, cette fois en pipelinant l'accès à la mémoire RAM interne au cache. Pour comprendre comment, rappelons qu'une mémoire SRAM est composée d'un plan mémoire et d'un décodeur. L'accès à la mémoire demande d'abord que le décodeur fasse son travail pour sélectionner la case mémoire adéquate, puis ensuite la lecture ou écriture a lieu dans cette case mémoire. L'accès se fait donc en deux étapes successives séparées, on a juste à mettre un registre entre les deux. Il suffit donc de mettre un gros registre entre le décodeur et le plan mémoire. [[File:Cache directement adressé pipeliné - trois étages.png|centre|vignette|upright=2|Cache directement adressé pipeliné - trois étages]] Et on peut aller encore plus loin en découpant le décodeur en deux circuits séparés. En effet, rappelez-vous le chapitre sur les circuits de sélection : nous avions vu qu'il est possible de créer des décodeurs en assemblant des décodeurs plus petits, contrôlés par un circuit de prédécodage. Et bien on peut encore une ajouter un registre entre ce circuit de prédécodage et les petits décodeurs. : Théoriquement, toute l'adresse est fournie d'un seul coup au cache, la quasi-totalité des processeurs présentent l'adresse complète à un cache pipeliné. Mais le Pentium 4 fait autrement. Il faut noter que les premiers étages manipulent l'indice dans la SRAM, qui est dans les bits de poids faible, alors que les étages ultérieurs manipulent le ''tag'' qui est dans les bits de poids fort. Les concepteurs du Pentium 4 ont alors décidé de présenter les bits de poids faible lors du premier cycle d'accès au cache, puis ceux de poids fort au second cycle. Pour cela, l'ALU fonctionnait à une fréquence double de celle du processeur, tout comme le cache L1. Il n'y avait pas de pipeline proprement dit, mais cela réduisait grandement la latence d'accès au cache. ===Le ''pipelining'' des caches splittés/sériels=== Il est aussi possible de pipeliner un cache dits splittés, aussi appelés à accès sériel. Pour rappel, les caches à accès sériel vérifient si il y a succès ou défaut de cache, avant d'accéder aux lignes de cache en cas de succès. Ils font donc différemment des autres caches, qui accèdent à une ligne de cache, avant de déterminer s'il y a succès ou défaut en lisant le tag de la ligne de cache. Les caches sériels disposent de deux SRAM : une pour les tags des lignes de cache et une pour les données. Ils accèdent à la SRAM pour les tags, avant d’accéder à la SRAM des données en cas de succès de cache. Vu que l'accès se fait en deux étapes, une vérification des tags suivie de la lecture/écriture des données, il est facile à pipeliner. Pipeliner le cache permet de régler le problème des accès au cache L1, et elle est tout le temps utilisé sur les processeurs modernes. Mais que faire en cas de défaut de cache ? ==Les caches non bloquants== Un '''cache bloquant''' est un cache auquel le processeur ne peut pas accéder pendant un défaut de cache. Il faut attendre que la lecture ou écriture en RAM soit terminée avant de pouvoir utiliser de nouveau le cache. Un '''cache non bloquant''' n'a pas ce problème : on peut l'utiliser pendant un défaut de cache. Les caches non bloquants permettent de démarrer une nouvelle lecture ou écriture alors qu'une autre est en cours, ce qui permet d'exécuter plusieurs lectures ou écritures en même temps. ===Les ''Miss Handling Status Registers''=== Lors d'un défaut de cache, la mémoire RAM est consultée pendant le défaut de cache, mais le cache est inutilisé. Un défaut de cache n'utilise pas le cache, ce n'est qu'un accès en mémoire RAM, sur le bus mémoire (ou un accès aux niveaux de cache inférieurs, peu importe). Le cache en lui-même est laissé libre, rien n’empêche d'y accéder, il est en réalité intrinsèquement non-bloquant. Les caches, bloquants comme non-bloquants, sont en réalité composés d'une mémoire cache proprement dite, entourée de circuits qui servent d'interface entre le processeur et le cache lui-même. Et parmi les circuits tout autour, certains gèrent l'accès au cache lors d'un défaut de cache. Ils sont regroupés sous le terme de '''''Miss Handling Architecture''''' (MHA). La différence entre un cache bloquant et un cache non-bloquant est en réalité liée à l'implémentation de la MHA. Les caches bloquants coupent volontairement l'accès au cache lors d'un défaut, car les défauts sont plus simples à gérer ainsi. Le défaut de cache rapatrie une donnée depuis la RAM, qui sera écrite dans le cache. Et il ne faut pas qu'une tentative d'accès à cette donnée ait lieu avant qu'elle ne soit chargée. Mais ce blocage est général et touche tout le cache, alors que seule une ligne de cache est concernée par le défaut de cache. L'idée derrière un cache non-bloquant est que seule la ligne de cache est bloquée, mais les autres sont accessibles. L'idée est alors de mémoriser les lignes de cache concernées par le défaut de cache, afin d'en bloquer l'accès. À chaque accès, on vérifie si la ligne de cache est déjà réservée par un défaut de cache. La lecture/écriture est alors bloquée si c'est le cas, mais elle accepte les accès sinon. Pour cela, la ''Miss Handling Architecture'' contient des registres qui mémorisent des informations sur les défauts de cache en cours. Ils portent le nom de '''''miss status handling registers''''', que j’appellerais dorénavant MSHR, qui sont aussi appelés des '''''miss buffer'''''. Le contenu des MSHR varie beaucoup suivant le processeur, mais ces derniers stockent au minimum les informations suivantes : * Le numéro de la ligne de cache dans laquelle les données sont chargées. * Un bit de validité qui indique si le MSHR est vide ou pas, qui est mis à 0 quand le défaut de cache est résolu. * Un ou plusieurs champs de lecture/écriture, qui contiennent des informations sur la lecture/écriture. ** Pour une lecture, elle contient des informations sur la destination de la donnée, à savoir qui prévenir quand le défaut de cache est terminé. C'est parfois un nom/numéro de registre (celui dans lequel charger la donnée), mais c'est souvent le numéro de l'entrée dans le ''load/store queue''. ** Pour les écritures, elle contient la donnée à écrire, ou éventuellement un numéro de ''load/store queue'' où se trouve la donnée à écrire. Il faut noter que le bus mémoire ne peut gérer qu'un seul défaut de cache à la fois. Aussi, il est intéressant de regarder ce qui se passe lorsqu'un second défaut de cache survient, pendant qu'un premier est en cours. Dans ce cas, il y a deux réponses qui correspondent à deux types de caches non-bloquants, qui portent les noms barbares de caches de type succès après défaut et défaut après défaut. Sur le premier type, il ne peut pas y avoir plusieurs défauts de cache simultanés. Dès qu'un second défaut de cache survient, le cache stoppe son activité et on ne peut plus démarrer de nouvelle lecture/écriture, tant que le premier défaut de cache n'est pas résolu. Le second type est plus souple et autorise la survenue de plusieurs défauts de cache simultanés. Du moins, jusqu'à une certaine limite, car le cache ne peut supporter qu'un nombre limité d'accès mémoires simultanés (pipelinés). ===Les accès simultanés à une même ligne de cache=== Il arrive que le processeur fasse plusieurs accès mémoire simultanés à la même ligne de cache. Si la ligne de cache en question n'a pas encore été chargée dans le cache, alors on a plusieurs défauts de cache consécutifs pour la même ligne de cache, et le cache non-bloquant doit gérer la situation. Pour la suite, il va falloir faire une petite distinction entre les défauts primaires et secondaires. Imaginons qu'un défaut de cache ait lieu et demande à charger une donnée dans la ligne de cache numéro N. Il s'agit du premier défaut impliquant cette ligne de cache précise, ce qui lui vaut le nom de '''défaut de cache primaire'''. Mais par la suite, d'autres accès mémoire à la même ligne de cache ont lieu, alors que la ligne de cache n'est pas encore disponible. Dans ce cas, il s'agit de '''défauts de cache secondaires'''. Pour l'unité d'accès mémoire, les défauts de cache primaire et secondaire sont différents (ils prennent tous une entrée dans la ''load/store queue''). Mais pour le cache, ils ne correspondent qu'à un seul accès au cache : celui qui demande de charger la ligne de cache demandée. Les défauts de cache primaire et secondaire à la même ligne de cache se voient attribuer un MSHR unique. La gestion des défauts de cache secondaires dépend du cache non-bloquant. La solution la plus simple ne permet pas les défauts de cache secondaires. Le cache ne permet pas deux défauts de cache simultanés pour la même ligne de cache. Les autres solutions le permettent, en fusionnant des accès simultanés à la même ligne de cache en un seul au niveau des MSHR. Dans tous les cas, détecter les défauts de cache secondaires sont un problème qu'il faut détecter. La ''Miss Handling Architecture'' doit détecter les défauts de cache secondaire. Pour cela, elle procède comme suit. Lors de chaque défaut de cache, la MHA récupère le numéro de la ligne de cache associé. Il vérifie alors chaque MSHR pour vérifier s'il contient le numéro en question. S'il n'y a aucune correspondance dans les MSHR, alors c'est signe que le défaut de cache est un défaut primaire. Mais s'il y en a une, alors c'est un défaut secondaire. Évidemment, cela signifie que lors d'un défaut de cache, le numéro de ligne de cache est envoyé à tous les MSHR, pour comparaison. Les MSHR sont donc regroupés dans une mémoire associative, une sorte de mini-cache, faciliter l'implémentation. Lors d'un défaut de cache primaire, l'accès à un cache non-bloquant se fait comme suit : le processeur envoie une adresse au cache, accède à celui-ci, et détecte la survenue d'un défaut de cache. Il en profite alors pour attribuer une ligne de cache dans laquelle sera chargée ce défaut. L'attribution est très simple dans le cas des caches ''direct mapped'', ou associatifs par voie, pour lesquels l'attribution se fait assez simplement. Il mémorise alors cette information dans les MSHR, après avoir vérifié que le défaut de cache n'était pas un défaut secondaire. Lors des accès ultérieurs à une adresse proche, censée être dans la même ligne de cache, le processeur va encore une fois rencontrer un défaut de cache. Il va alors déterminer le numéro de la ligne de cache associée à l'adresse, et comparer ce numéro avec les MSHRs. Si un MSHR contient ce numéro, c'est signe que le défaut de cache est un défaut secondaire. La MHA réagit alors différemment selon le processeur considéré. Une première solution n'autorise pas les défauts de cache secondaires. Si l'un d'entre eux survient, le processeur est gelé par un ''pipeline stall'', une bulle de pipeline. Une autre solution fusionne les défauts de cache secondaires avec le défaut de cache primaire : tout cela ne correspond qu'à un seul défaut de cache pour lui. ===Les MSHR simples=== Un cache non-bloquant à '''MSHR simple''' contient juste plusieurs MSHR qui mémorisent juste un numéro de ligne de cache, un bit de validité, le champ de lecture/écriture, et l'adresse exacte de lecture/écriture. Avec cette organisation, il est possible d'avoir plusieurs défauts de cache séparés, mais à la condition que chaque défaut accède à une ligne de cache différente. Deux accès simultanés à une même ligne de cache ne sont pas possibles, les défauts de cache secondaires ne sont pas autorisés. Ainsi, chaque défaut de cache se voit attribuer son propre MSHR, chacun contient un numéro de ligne de cache différent. Pour comprendre pourquoi c'est impossible de gérer les défauts secondaires, il faut regarder le champ de lecture/écriture. Si on veut effectuer plusieurs écritures consécutives à la même adresse, le MSHR n'aura pas de quoi mémoriser les deux données à écrire. Il pourra mémoriser la première donnée à écrire, pas la seconde. Même chose lors d'une lecture : le champ lecture/écriture peut mémorisr la destination de la première lecture, pas de la seconde. L'avantage est que la MHA n'a besoin que des MSHR et de quelques circuits annexes. Les autres solutions rajoutent des circuits annexes pour gérer les défauts de cache secondaires, qui utilisent beaucoup de circuits. Le cout en circuit est donc élevé, mais le gain en performance est là. Passons maintenant aux caches non-bloquants qui autorisent les défauts de cache secondaire. La solution la plus simple consiste à utiliser ===Les MSHR adressés implicitement=== Avec les '''MSHR adressés implicitement''', il est possible de fusionner plusieurs accès mémoire à une même ligne de cache, mais sous une condition très importante : ces accès lisent/écrivent des mots mémoire différents. Par exemple, imaginons qu'une ligne de cache contienne 8 mots mémoire de 64 bits. Si un premier accès mémoire lit le mot mémoire numéro 7 (dernier mot mémoire de la ligne), et le second accès le mot mémoire numéro 3, alors la fusion est possible. Mais si deux accès mémoire veulent lire/écrire le mot mémoire numéro 7, alors la fusion n'est pas possible et le processeur se bloque, un ''pipeline stall'' survient. Un MSHR adressé implicitement est un MSHR simple, qui contient naturellement un numéro de ligne de cache (tag) et un bit de validité, sauf que le champ de lecture/écriture est dupliqué. Les différents champs lecture/écriture d'un MSHR sont regroupés dans une mémoire RAM/cache qui contient autant d'entrées qu'il y a de mot mémoire dans une ligne de cache. Chaque entrée de la table est associée à un mot mémoire de la ligne de cache et stocke des informations sur celui-ci. Une entrée mémorise, au minimum : * Un champ de lecture/écriture qui contient soit la destination de la lecture, soit la donnée à écrire. * Un bit R/W permettant d'interpréter correctement le champ de lecture/écriture. * Un bit de validité pour chaque entrée de la table, qui dit si un défaut de cache antérieur accède déjà à ce mot mémoire. La fusion de deux défauts de cache est ainsi assez simple. Un défaut de cache primaire/secondaire configure l'entrée associée au mot mémoire lu/écrit. Si un défaut secondaire ultérieur lit/écrit un mot mémoire différent (dont le bit de validité de l'entrée dans la table est à zéro), pas de conflit : il configure une autre entrée, vide. Mais s'il lit/écrit un mot mémoire pour lequel l'entrée de la table est occupée, il y a conflit, le processeur est bloqué par un ''pipeline stall'', une bulle de pipeline. Avec cette organisation, le nombre de MSHR indique combien de lignes de cache peuvent être lues en même temps depuis la mémoire. Quant au nombre d'entrées par MSHR, il détermine combien d'accès mémoires qui ne se recouvrent pas peuvent avoir lieu en même temps. ===Les MSHR adressés explicitement=== Les '''MSHR adressés explicitement''' n'ont pas les contraintes des MSHR adressés implicitement. Avec eux, il est possible d'avoir plusieurs défauts de cache pointant vers la même ligne de cache, mais aussi vers le même mot mémoire. De tels défauts de cache apparaissent sur les processeurs à exécution dans le désordre, mais sont très rares, voire inexistants, sur les processeurs ''in-order''. L'idée est encore que chaque MSHR est associée à une table mémoire qui mémorise des entrées. Cette table n'est autre qu'une mémoire FIFO. Sauf que cette fois-ci, une entrée n'est pas associée à un mot mémoire. Une entrée est associée à un défaut de cache. Une entrée mémorise là encore la destination de la lecture ou la donnée de l'écriture suivi du bit R/W, ainsi que d'un bit de validité par entrée, mais aussi : la position du mot mémoire lu dans la ligne de cache. C'est cette dernière information qui n'était pas présente dans les MSHR adressés implicitement. De plus, le nombre d'entrée par MSHR n'est pas égal au nombre de mots mémoires dans une ligne de cache, mais peut être arbitrairement grand. L'avantage d'un tel cache est qu'il est capable de traité les défauts secondaires concernant un même mot mémoire, et ce, car les accès à la ligne de cache concernée se produisent dans l'ordre de génération des défauts (la table mémoire est une FIFO). ===Les MSHRs inversés=== Généralement, plus on veut supporter de défauts de cache, plus le nombre de MSHR et d'entrées augmente. Mais au-delà d'un certain nombre d'entrées et de MSHR, les MSHR adressés implicitement et explicitement ont tendance à bouffer un peu trop de circuits. Utiliser une organisation un peu moins gourmande en circuits est donc une nécessité. Cette organisation plus économe se base sur des MSHR inversés. Les MSHR inversés ne contiennent qu'une seule entrée, en quelque sorte : au lieu d’utiliser n MSHR de m entrées chacun, on va utiliser n × m MSHR inversés. La différence, c'est que plusieurs MSHR peuvent contenir un tag identique, contrairement aux MSHR adressés implicitement et explicitement. Lorsqu'un défaut de cache a lieu, chaque MSHR est vérifié. Si jamais aucun MSHR ne contient de tag identique à celui utilisé par le défaut, un MSHR vide est choisi pour stocker ce défaut, et une requête de lecture en mémoire est lancée. Dans le cas contraire, un MSHR est réservé au défaut de cache, mais la requête n'est pas lancée. Quand la donnée est disponible, les MSHR correspondant à la ligne qui vient d'être chargée vont être utilisés un par un pour résoudre les défauts de cache en attente. ===Les MSHR intégrés au cache=== Certains chercheurs ont remarqué que pendant qu'une ligne de cache est en train de subir un défaut de cache, celle-ci reste inutilisée, et son contenu est destiné à être perdu une fois le défaut de cache résolu. Ils se sont dit que, plutôt que d'utiliser des MSHR séparés, il vaudrait mieux utiliser la ligne de cache pour stocker les informations sur les défauts de cache en attente dans cette ligne de cache. Pour éviter tout problème, il faut rajouter un bit dans les bits de contrôle de la ligne de cache, qui sert à indiquer que la ligne de cache est occupée : un défaut de cache a eu lieu dans cette ligne, et elle stocke donc des informations pour résoudre les défauts de cache. ==L'entrelacement mémoire== Nous venons de voir comment une mémoire cache peut gérer plusieurs accès mémoire simultanés. Il se trouve que ce genre d'optimisation dépasse largement le cadre du cache. Les mémoires RAM ne sont pas en reste, elles aussi ! Il est possible de pipeliner l'accès à une mémoire RAM, en utilisant des techniques dites d''''entrelacement'''. Précisons cependant que cette optimisation n'est pas utilisée sur les mémoires SDRAM ou DDR modernes, du moins pas sans modifications majeures. Mais divers ordinateurs assez anciens, dont des superordinateurs, ont utilisé cette technique. La technique demande d'utiliser plusieurs mémoires séparées. Sur les anciens ordinateurs, les mémoires en question sont des chips mémoire, à savoir des circuits intégrés de DRAM. Pour faire plus général, nous allons utiliser le terme de '''banques''', ou encore de bancs mémoire. La différence est qu'il existe des mémoires multi-banque, qui regroupent plusieurs banques indépendantes dans un seul boitier, dans un seul chip mémoire. En clair, de telles mémoires regroupent plusieurs sous-mémoires dans un seul circuit intégré. Il est aussi possible d'utiliser plusieurs chips mémoire séparés, chacun ayant une banque. Reste à combiner ces banques pour former un pseudo-pipeline. ===Utiliser plusieurs banques sans entrelacement=== Sans optimisation particulière, les adresses sont réparties dans les banques comme indiqué ci-dessous. Il s'agit de ce qui s'appelle un arrangement horizontal, et nous avions vu celui-ci dans le chapiutre sur les mémoires SDRAM. [[File:Répartition des adresses sans entrelacement.png|centre|vignette|upright=1.5|Répartition des adresses sans entrelacement.]] Avec cet arrangement, les bits de poids fort de l'adresse sont utilisées pour sélectionner la banque adéquate, et le reste de l'adresse est envoyé sur le bus d'adresse. Les adresses mémoire sont alors découpées comme suit : {|class="wikitable" |+ Adresse mémoire |- ! Adresse de banque !! Adresse dans la banque |- | Quelques bits de poids fort || Reste de l'adresse |} L'entrelacement change cette répartition, afin d'accélérer les accès mémoire. L'entrelacement classique vise à améliorer les accès à des adresses mémoires consécutives, mais des formes plus évoluées d'entrelacement visent à optimiser des accès mémoire arbitraires. Voyons-les dans l'ordre, du plus simple au plus complexe. ===L'entrelacement classique=== Avec l''''entrelacement classique''', des adresses consécutives sont réparties dans des banques consécutives. Précisons que cette attribution des adresses n'implique absolument pas la mémoire virtuelle ou n'importe quel mécanisme dans le processeur. La répartition décide que telle adresse mémoire va dans telle banque, à telle adresse dans la banque. Elle est donc le fait du contrôleur mémoire, donc en dehors du processeur (les contrôleurs mémoires n’étaient pas encore intégrés dans le processeur à l'époque). [[File:Répartition des adresses dans une mémoire interleaved.png|centre|vignette|upright=1.5|Répartition des adresses dans une mémoire interleaved.]] L'entrelacement simple permet d'accélérer les accès mémoire qui se font à des adresses consécutives. Avec l'entrelacement, chaque accès mémoire tombe dans une banque mémoire différente, ce qui fait qu'on peut démarrer un nouvel accès mémoire à chaque cycle d'horloge. Pendant qu'une banque est occupée par un accès mémoire, on démarre le suivant dans une autre banque, et ainsi de suite. Le tout se termine soit quand on a épuisé toutes les banques libres, soit quand l'accès en rafales se termine. Pas besoin d'attendre que la mémoire ait fini sa lecture/écriture avant de démarrer la lecture/écriture suivante. [[File:Pipemining mémoire.png|centre|vignette|upright=2.5|Pipelining mémoire]] L'animation suivante illustre bien le processus, avec quatres banques. [[File:Interleaving.gif|centre|vignette|upright=2.5|Pipelining mémoire]] L'entrelacement simple utilise les bits de poids faible pour sélectionner la banque, et les bits de poids fort pour la case mémoire. [[File:Adresse mémoire d'une mémoire entrelacée.png|centre|vignette|upright=2|Adresse mémoire d'une mémoire entrelacée]] Précisons que le temps d'accès mémoire ne change pas beaucoup avec l'entrelacement. Par contre, on peut faire plus d'accès mémoire simultanés. On peut démarrer un accès mémoire par cycle d'horloge, mais l'accès en lui-même prend plusieurs cycles. Les mémoires à entrelacement ont donc un débit supérieur aux mémoires qui ne l'utilisent pas. L'entrelacement classique pourrait être utilisé pour accélérer les transferts entre mémoire RAM et cache, qui se font en blocs d'adresses consécutives. Mais de nos jours, il n'est pas utilisé dans cette optique. Il faut dire qu'il est redondant avec le mode rafale des mémoires DRAM. Les deux font globalement la même chose et on voit mal comment utiliser les deux en même temps. Le mode rafale étant plus simple à implémenter, et plus léger niveau utilisation du bus de commande mémoire, il est préféré à l'entrelacement. Par contre, l'entrelacement a eu son heure de gloire sur d'anciens processeurs qui n'avaient pas de mémoire cache, alors qu'ils disposaient d'un pipeline. L'exemple typique est celui des nombreux ''processeurs vectoriels'', que nous n'avons pas encore abordé à ce stade du cours. De tels processeurs avaient un pipeline, une unité de calcul fortement pipelinée, et parfois même de l'exécution dans le désordre et du renommage de registres ! ===L'entrelacement par décalage=== L'entrelacement simple est très utile pour les accès en rafle ou équivalents. Par contre, il existe de rares situations où il n'est pas optimal. Une de ces situations est celle des accès en enjambée (en ''stride''), où l'on accède en série à des adresses sont séparées par N mots mémoires. [[File:Accès par enjambées.png|centre|vignette|upright=2|Accès par enjambées.]] De tels accès surviennent quand un logiciel accède à des structures de données spécifiques, à savoir des tableaux de structures, des matrices, ou d'autres structures de données dans le genre. Un cas classique est celui du parcours d'une matrice colonne par colonne. Les matrices de nombres sont mémorisées ligne par ligne, ce qui fait que parcourir une colonne demande de passer d'une ligne à la suivante et de faire des grands sauts en mémoire RAM, mais tous espacés par la même distance. Avec l'entrelacement simple, les accès en enjambée sont moins performants que les accès à des adresses consécutives, mais cela ne fonctionne pas trop mal. Le pire cas est celui où l'on a N banques et où les données sont justement placées toutes les N adresses. Des accès consécutifs vont tous tomber dans la même banque, on ne peut plus accéder à des banques différentes en parallèle. Mais en dehors de ce cas, on voit une amélioration par rapport à la situation sans entrelacement. Pour obtenir des performances maximales pour les accès en enjambées, il faut répartir les mots mémoires dans la mémoire autrement. L'organisation idéale est la suivante. : Dans les explications qui vont suivre, la variable N représente le nombre de banques, qui sont numérotées de 0 à N-1. On commence par organiser les N premières adresses comme une mémoire entrelacée simple : l'adresse 0 correspond à la banque 0, l'adresse 1 à la banque 1, etc. Pour le bloc suivant, on décale tout d'une adresse, à savoir qu'on commence à remplir les banques à partir de la seconde, non de la première. Une fois la fin du bloc atteinte, on finit de remplir le bloc en repartant du début du bloc. Le troisième bloc d'adresse subit le même traitement, sauf qu'on commence à remplir à partir de la seconde banque. Et on poursuit l’assignation des adresses en décalant d'un cran en plus à chaque bloc. Ainsi, chaque bloc verra ses adresses décalées d'un cran en plus comparé au bloc précédent. Si jamais le décalage dépasse la fin d'un bloc, alors on reprend au début. [[File:Mémoire interleaved par décalage.png|centre|vignette|upright=1.5|Mémoire entrelacée par décalage.]] En faisant cela, on remarque que les banques situées à N adresses d'intervalle sont différentes. Dans l'exemple du dessus, nous avons ajouté un décalage de 1 à chaque nouveau bloc à remplir. Mais on aurait tout aussi bien pu prendre un décalage de 2, 3, etc. Dans tous les cas, on obtient un entrelacement par décalage. Ce décalage est appelé le pas d'entrelacement, noté P. Le calcul de l'adresse à envoyer à la banque, ainsi que la banque à sélectionner se fait en utilisant les formules suivantes : * adresse à envoyer à la banque = adresse totale / N ; * numéro de la banque = (adresse + décalage) modulo N ; * décalage = (adresse totale * P) mod N. Avec cet entrelacement par décalage, on peut prouver que la bande passante maximale est atteinte si le nombre de banques est un nombre premier. Seulement, utiliser un nombre de banques premier peut créer des trous dans la mémoire, des mots mémoires inadressables. Malgré ce défaut, la technique a été utilisée sur quelques ordinateurs, avec l'exemple notable du superordinateur ''Burroughs Scientific Processor''. Pour éviter cela, il y a plusieurs solutions. Par exemple, on peut faire en sorte que N et la taille d'une banque soient premiers entre eux : ils ne doivent pas avoir de diviseur commun. Mais en pratique, elles n'ont pas vraiment été implémentées dans une vraie machine, et sont restées à l'état de recherche, aussi je les passe sous silence. ===L'entrelacement pseudo-aléatoire=== Une dernière méthode de répartition consiste à répartir les adresses dans les banques de manière "pseudo-aléatoire". L'idée exacte est de faire passer l'adresse dans une '''fonction de hachage''', plus ou moins complexe. Pour rappel, une fonction de hachage prend une entrée de grande taille et fournit en sortie un résultat de petite taille. Idéalement, elles doivent donner des résultats aussi différents que possible pour des entrées similaires, histoire de simuler une sorte de pseudo-aléatoire. Ici, une adresse est transformée en un numéro de banque. Une fonction de hachage simple ne fait que permuter des bits de l'adresse pour obtenir son résultat. Elle ne fait qu'échanger des bits de place avant de couper l'adresse en deux morceaux : un pour la sélection de la banque, et un autre pour la sélection de l'adresse dans la banque. Cette permutation est fixe, et ne change pas suivant l'adresse. Des fonctions de hachage plus complexes font des XOR bit à bit entre certains bits de l'adresse. ==Les contrôleurs SDRAM/DDR avec "exécution dans le désordre"== Après avoir vu le cas des mémoires RAM, nous allons nous concentrer sur le cas particulier des mémoires SDRAM. Les mémoires SDRAM et DDR modernes sont capables de gérer plusieurs accès simultanés à la mémoire RAM, et leurs contrôleurs mémoire en font tout autant. C'est une différence majeure avec les mémoires asynchrones FPM/EDO, qui n'acceptaient qu'un seul accès mémoire à la fois. Leur contrôleur mémoire n'acceptait qu'un seul accès mémoire à la fois, c'était un contrôleur mémoire bloquant. Les contrôleurs mémoires des SDRAM sont eux non-bloquants et peuvent encaisser une dizaine d'accès mémoire à la fois. Peu de choses sont connues sur les contrôleurs de SDRAM/DDR modernes, les fabricants ne donnant que peu de détails dessus. Les rares simulateurs qui tentent de décrire leur fonctionnement, comme DRAM SIM I et II, sont particulièrement simples et ne vont pas dans le détail. Néanmoins, le peu qu'on sait est tout de même instructif. ===Rappel sur les SDRAM : tampon de ligne et commandes=== Les mémoires SDRAM sont des mémoires à tampon de ligne. Elles font un accès mémoire en deux étapes. La première étape recopie une ligne de N * 64 bits dans un tampon interne à la SDRAM. La seconde étape sélectionne une colonne, à savoir une donnée de 64 bits, qui est soit envoyée sur le bus de données pour une lecture, soit modifiée par une écriture. [[File:Mémoire à tampon de ligne.png|centre|vignette|upright=2|Mémoire à tampon de ligne]] Les accès mémoire sont traduits par un séquenceur mémoire en une série de commandes mémoires, qui sont séparées par des délais mémoire de quelques cycles d'horloge. Les délais sont très précis, et sont à respecter à la lettre. Une lecture ou une écriture se fait en maximum trois commandes : une commande PRECHARGE qui ferme la ligne précédemment utilisée, une commande ACT qui précise l'adresse de la ligne, et une commande READ ou WRITE qui précise l'adresse de la colonne et éventuellement la donnée à écrire. : Pour être plus précis, la commande PRECHARGE précharge les lignes de bits du plan mémoire à une tension particulière, ce qui les vide de leur contenu. Mais c'est un détail sans importance pour ce qui va suivre. Les SDRAM permettent de se passer de la première étape si des accès consécutifs se font dans la même ligne. Une fois activée, la ligne reste ouverte et on peut accéder plusieurs fois de suite dedans. On a alors juste à préciser l'adresse de colonne dedans. Si un accès mémoire accède à une ligne déjà activée, on dit que c'est un '''succès de page'''. Si ce n'est pas le cas, on doit fermer la ligne courante, rouvrir la ligne voulue, et préciser la colonne. C'est alors un '''défaut de page'''. {|class="wikitable" |- ! !! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! Cycle 10 !! Cycle 11 !! Cycle 12 !! Cycle 13 |- ! Bus de commande/adresse | bgcolor="#FFA0FF" | PRECHARGE || bgcolor="#FFA0FF" | ACT || || bgcolor="#A0FFFF" | READ (1) || || bgcolor="#A0FFFF" | READ (2) || bgcolor="#A0FFFF" | READ (3) || || || bgcolor="#A0FFFF" | READ (4) || bgcolor="#A0FFFF" | READ (5) || || |- ! Bus de données | || || || || || bgcolor="#A0FFFF" | Donnée READ 1 || ||bgcolor="#A0FFFF" | Donnée READ 2 | bgcolor="#A0FFFF" | Donnée READ 3 || || || bgcolor="#A0FFFF" | Donnée READ 4 || bgcolor="#A0FFFF" | Donnée READ 5 |} Les SDRAM sont parfois capables de démarrer une commande avant que la précédente soit terminée. Mais le respect des délais mémoire est très limitant, ce qui fait qu'on ne peut pas parler de réel pipeline, comme c'est le cas sur les processeurs. Parlons plutôt de '''commandes anticipées'''. {|class="wikitable" |- ! !! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! Cycle 10 !! Cycle 11 !! Cycle 12 !! Cycle 13 |- ! Bus de commande/adresse | || bgcolor="#A0FFFF" | ACT || || bgcolor="#A0FFFF" | READ (1) || || || || bgcolor="#FFA0FF" | READ (2) || || || || || |- ! Bus de données | || || || || || bgcolor="#A0FFFF" | READ 1 || bgcolor="#A0FFFF" | READ 1 || bgcolor="#A0FFFF" | READ 1 || bgcolor="#A0FFFF" | READ 1 | bgcolor="#FFA0FF" | READ 2 || bgcolor="#FFA0FF" | READ 2 || bgcolor="#FFA0FF" | READ 2 || bgcolor="#FFA0FF" | READ 2 | |} Un dernier point est que les chips mémoires de SDRAM sont composés de plusieurs banques, chacune étant une sorte de mini-mémoire miniature. Chaque banque a son propre décodeur, son propre tampon de ligne, ses propres multiplexeurs de colonne, sa logique de rafraichissement mémoire, etc. C'est comme si une SDRAM regroupait plusieurs mémoires séparées dans un même circuit intégré. [[File:Arrangement vertical.jpg|centre|vignette|upright=2.5|Mémoire multi-banques.]] ===La mise en attente des accès mémoire=== Un contrôleur de SDRAM peut accepter plusieurs accès mémoire en même temps. Mais cela ne signifie pas que le contrôleur sera prêt à les traiter immédiatement. Pour éviter tout problème de disponibilité, le contrôleur met en attente les accès mémoire que le processeur lui envoie, pour les exécuter dès que possible. Les accès mémoire sont mis en attente dans une mémoire FIFO, histoire de les exécuter dans leur ordre d'arrivée, notamment pour renvoyer les lectures dans l'ordre demandé. Il y a aussi une mémoire FIFO pour les données à écrire et une FIFO pour les données lues. Cette dernière sert au cas où le cache ne soit pas disponible quand on lui envoie la donnée lue. [[File:Module d'interface avec la mémoire.png|centre|vignette|upright=2.5|Contrôleur mémoire avec mise en attente des requêtes processeur et autres optimisations.]] ===Les lectures anticipées : une forme d'OOO mémoire=== La mise en attente des accès mémoire est une optimisation intéressante, mais pas mirifique. Elle permet surtout de ne pas bloquer le processeur si le contrôleur mémoire a du travail sur la planche. Cependant, elle peut être optimisée quand on se rend compte d'une chose : le processeur ne voit que les lectures, pas les écritures. Pour les écritures, il les envoie au contrôleur mémoire et ce dernier fait le travail demandé, le processeur n'est pas prévenu quand une écriture se termine. Mais pour les lectures, c'est différent, car il reçoit la donnée lue. En conséquence, il est possible d'optimiser les écritures sans que le processeur ne voie quoique ce soit. Une optimisation possible est alors de changer l'ordre des accès mémoire, histoire de retarder les écritures ou au contraire de les faire en avance. La raison est que les mémoires SDRAM n'aiment pas quand on alterne lectures et écritures. Les délais mémoire, les fameux ''timings'' mémoire, sont clairs : faire une suite de lecture ou une suite d'écriture est plus performant que d'alterner entre lectures et écritures. L'idée est alors de changer l'ordre des écritures de manière à les faire en bloc, idem pour les lectures. L’optimisation est possible, mais à condition que le processeur n'y voie que du feu. Or, vous l'avez deviné, il y a des cas où ces réorganisations ne sont pas possibles, et vous avez sans doute trouvé que la situation était familière. L'optimisation en question est une forme d'exécution dans le désordre des accès mémoire, réalisée par le contrôleur mémoire lui-même ! Et qui dit exécution dans le désordre dit : problèmes liés aux dépendances de données. Changer l'ordre des écritures n'est pas toujours possible, notamment en raison des dépendances RAW, WAR et WAW. Pour que le processeur n'y voie que du feu, il faut respecter plusieurs critères. Premièrement, les lectures doivent être renvoyées au processeur dans l'ordre. Et quand je dis dans l'ordre, cela veut dire : dans l'ordre d'envoi des lectures au contrôleur mémoire. Les écritures aussi doivent se faire dans l'ordre, histoire qu'une série d'écriture donne le bon résultat final. Le troisième critère est qu'une lecture doit donner le résultat de la dernière écriture ''à la même adresse''. Pour le dire autrement, il faut juste détecter les dépendances RAW. Concrètement, si on a une série de lectures et d'écritures alternées, on peut regrouper les lectures et les écritures dans deux paquets séparés, à condition qu'aucune lecture ne lise une adresse écrite par une écriture. Mais si une lecture est dépendante d'une écriture, alors la lecture doit attendre que l'écriture se termine, idem pour les lectures suivantes. Une implémentation basique remplace la mémoire FIFO vue au-dessus, par deux mémoires FIFOs : une pour les lectures, une pour les écritures. Lorsqu'un accès mémoire arrive au séquenceur, il regarde si c'est une lecture ou une écriture et place l'accès mémoire dans la file adéquate. Le fait que ce soit des FIFOs garantit que les lectures se font dans l'ordre, idem pour les écritures. En sortie des deux FIFOs, le séquenceur mémoire détecte les dépendances RAW. Précisément, quand une lecture sort de la mémoire FIFO, il consulte la file d'attente des écritures pour voir si la lecture est dépendante d'une écriture en attente. La lecture attend si c'est le cas, et les écritures sont exécutées à la place. Séparer lectures et écriture est une source d'optimisation. Il est par exemple possible d'utiliser le réacheminement lecture sur écriture (''Store to load forwarding''). Il s'agit d'un équivalent de l’optimisation du même nom, utilisée dans le cadre de la désambiguïsation mémoire. Imaginez qu'une lecture accède à une donnée pas encore écrite, en attente dans le contrôleur mémoire. C'est une dépendance RAW assez claire. Dans ce cas, le contrôleur mémoire peut renvoyer la donnée directement depuis ses mémoires FIFOs, sans faire d'accès mémoire en lecture. Le contrôleur mémoire a tendance à privilégier les lectures, car celles-ci sont cruciales pour l'exécution dans le désordre. Plus elles se font tôt, plus vite le processeur recevra les données et pourra démarrer les instructions dépendantes de cette donnée. Le séquenceur a donc tendance à piocher en priorité dans la file de lecture, plutôt que dans la file d'écriture. À vrai dire, dans le cas idéal, les écritures ne sont faites que quand la file d'écriture est pleine ou quasi pleine... Prenons l'exemple des coprocesseurs IO 81341 et 81342, qui étaient en réalité des ''chipsets'' intégrant un contrôleur SDRAM. Le ''chipset'' avait 5 ports : un pour les processeurs, un pour le pont sud (''southbridge'') et trois pour des canaux DMA. Pour le port processeur, il n'y avait pas de réordonnancement et ce port utilisait une seule mémoire FIFO. Les autres ports utilisaient l'optimisation qu'on vient de voir, et avaient vraisemblablement des files séparées pour les lectures et écritures. Le séquenceur mémoire vérifiait les dépendances mémoire de type RAW et autres. [[File:Double file d'attente pour les lectures et écritures.png|centre|vignette|upright=2.5|Double file d'attente pour les lectures et écritures]] ===Le ré-ordonnancement des commandes mémoires=== L'optimisation précédente est peu poussée, comparé aux formes d'exécution dans le désordre que les processeurs utilisent. Et on peut se demander si une exécution dans le désordre plus poussé est possible. La réponse est oui : un contrôleur mémoire peut faire des réorganisations bien plus poussées. Par contre, il faut faire une précision très importante : le contrôleur mémoire ne remet pas les accès mémoire dans l'ordre avant de les envoyer au processeur. C'est le processeur qui remet en ordre les accès mémoire, dans sa ''load queue''. En clair : le processeur voit les accès mémoire dans le désordre, c'est lui qui les remet en ordre. Pour cela, les requêtes mémoire sont "numérotées", à savoir qu'on leur attribue un identifiant binaire. Le contrôleur mémoire exécute les lectures/écritures dans le désordre, mais garde la trace de leur identifiant. Il sait dans quel ordre il exécute les accès mémoire et connait leur latence. Ce qui fait qu'il sait que la donnée lue à tel instant est associée à la requête mémoire numéro X. Pour les lectures, il renvoie la donnée lue avec l'identifiant. Le processeur sait alors à quelle lecture la donnée lue correspond et il se débrouille en interne pour gérer la situation. Maintenant que ces précisions sont faites, posons cette question : dans quelles situations est-il pertinent de faire des accès mémoire dans le désordre ? La réponse est : quand plusieurs accès à une même ligne ne sont pas consécutifs, qu'il y a des accès entre les deux. Après ré-ordonnancement, ces accès mémoire à une même ligne sont exécutés l'un à la suite de l'autre, ce qui est beaucoup plus rapide. Pour rendre le tout plus concret, voici un exemple. Imaginez que l'on sait les 3 accès mémoire suivants : * Une lecture ligne A ; * PRECHARGE + ACT ; * Une écriture ligne B ; * PRECHARGE + ACT ; * Une lecture ligne A. Sans réordonnancent, on doit émettre deux commandes PRECHARGE quand on passe d'une ligne à l'autre, et il faut ajouter les commandes ACT avec. Pour éviter cela, le contrôleur mémoire peut retarder l'écriture, ou au contraire avancer la seconde lecture. Le résultat est alors le suivant : * Lecture ligne A ; * Lecture ligne A * PRECHARGE + ACT ; * Une écriture ligne B. On a donc deux accès consécutifs à la même ligne, suivi par une écriture dans une autre ligne. La technique marche parce que l'on a un mix adéquat de lectures et d'écritures. Et encore une fois, il faut éviter d'intervertir lectures et écritures à une même adresse, sous peine de problèmes. Encore une fois, les dépendances RAW posent problème ! L'implémentation est assez simple : la ou les mémoires FIFOs précédentes sont remplacées par des mémoires similaires aux ''schedulers''. Le contrôleur mémoire vérifie à chaque cycle les accès en attente, et vérifie à quelle ligne ils accèdent. Il priorise alors les accès qui tombent dans la ligne ouverte : ceux-là sont exécutés avant les autres. S'il n'y en a pas, il prend l'accès mémoire le plus ancien (ordre FIFO). Il teste aussi les dépendances mémoires RAW avant d'envoyer des commandes à la mémoire DDR/SDRAM. une telle solution est appelée l''''algorithme ''FR-FCFS''''' (''First Ready-First Come First Serve''). ===Les optimisations liées à la présence de plusieurs banques=== Les optimisations précédentes sont décuplées par la présence de plusieurs banques dans la mémoire SDRAM. Il est en effet possible de faire plusieurs accès mémoire en même temps, dans des banques différentes. Il est possible de lancer un accès mémoire dans deux banques en même temps, par exemple. La seule contrainte est que la SDRAM est limitée à 4 banques actives en même temps. {|class="wikitable" |+ Pipelining basique sur les SDRAM |- ! !! Cycle 1 !! Cycle 2 !! Cycle 3 !! Cycle 4 !! Cycle 5 !! Cycle 6 !! Cycle 7 !! Cycle 8 !! Cycle 9 !! Cycle 10 !! Cycle 11 |- ! Banque Numéro 1 | || || || colspan="3" bgcolor="#FFFFA0" | Accès mémoire || colspan="3" bgcolor="#FFFFA0" | Accès mémoire || || |- ! Banque Numéro 2 | || colspan="3" bgcolor="#A0FFFF" | Accès mémoire || || || colspan="3" bgcolor="#A0FFFF" | Accès mémoire || || |- ! Banque Numéro 3 | || || || colspan="3" bgcolor="#FFA0FF" | Accès mémoire || || colspan="3" bgcolor="#FFA0FF" | Accès mémoire || |} Les optimisations précédentes peuvent s'appliquer par banque. Il est par exemple possible d'utiliser une mémoire FIFO par banque. Ainsi, les accès dans une même banque se feront en série, dans l'ordre d'arrivée, mais des accès à des banques différentes se feront dans le désordre. Cela ne pose pas de problème, car les accès se font à des adresses différentes, ce qui fait qu'il n'y a pas de conflits majeurs. Il y a cependant un risque que les lectures se fassent dans le désordre si on n'y prend pas garde. [[File:Gestion parallèle des banques.png|centre|vignette|upright=2.5|Gestion parallèle des banques.]] Il est aussi possible d'utiliser une file séparée pour les lectures et les écritures, pour chaque banque. Les performances sont alors améliorées comparé à deux files globales pour toute la SDRAM. En idem avec la réorganisation des accès mémoire, pour regrouper les accès à une même ligne. Un problème avec ces optimisations est qu'il est rare que des accès mémoire se fassent dans des banques séparées. Les accès mémoire tendent à se faire avec une bonne localité spatiale, ce qui fait qu'ils tombent dans une même banque. Heureusement, les contrôleurs SDRAM/DDR modernes incorporent des optimisations pour corriger ce problème. Les optimisations en question sont une forme d'entrelacement adaptée aux mémoires SDRAM. L'entrelacement naïf ne marche pas à cause de la présence du tampon de ligne. Cependant, il peut y avoir un entrelacement entre banques SDRAM. Voyons ce que ça veut dire. L''''entrelacement de banques''' répartit deux lignes consécutives dans deux banques différentes. Pour comprendre l'idée, prenons un exemple. Imaginons une mémoire avec deux banques et 4 lignes. Imaginons qu'on parcoure/balaye la mémoire RAM en partant des adresses basses. Sans entrelacement de ligne, les accès se feront comme suit, de gauche à droite : {|class="wikitable" |+ Adresse mémoire |- ! colspan="4" | Banque numéro 1 !! colspan="4" | Banque numéro 2 |- | Ligne 1 || Ligne 2 || Ligne 3 || Ligne 4 || Ligne 1 || Ligne 2 || Ligne 3 || Ligne 4 |} Avec l'entrelacement de banques, les accès se feront comme suit : {|class="wikitable" |+ Adresse mémoire |- ! Banque n°1 !! Banque n°2 !! Banque n°1 !! Banque n°2 !! Banque n°1 !! Banque n°2 !! Banque n°1 !! Banque n°2 |- | Ligne 1 || Ligne 1 || Ligne 2 || Ligne 2 || Ligne 3 || Ligne 3 || Ligne 4 || Ligne 4 |} L'avantage est que passer d'une ligne à la suivante est plus rapide. Pas besoin de fermer la ligne pour passer à la suivante, on ouvre directement la ligne suivante dans une banque différente, ce qui sera plus rapide. La commande PRECHARGE pourra être envoyée à la seconde banque en avance. Pour gérer l'entrelacement, le contrôleur de SDRAM prend en entrée l'adresse envoyée par le processeur, et la découpe en plusieurs champs : un pour sélectionner la banque adéquate, un autre pour la ligne, un autre pour la colonne, un autre pour la rangée. Sans entrelacement, les adresses mémoire sont découpées comme suit : {|class="wikitable" |+ Adresse mémoire |- | Adresse de banque || Adresse de ligne || Adresse de colonne |} Avec l'entrelacement de banques, l'adresse est découpée comme suit. {|class="wikitable" |+ Adresse mémoire |- | Adresse de ligne || Adresse de banque || Adresse de colonne (précise à l'octet) |} Il est aussi possible de faire la même chose, mais avec les rangées ou en utilisant du ''dual channel'', mais je vais passer cela sous silence. Toujours est-il que les méthodes d'entrelacement sont nombreuses et chaque contrôleur mémoire fait un peu à sa sauce. Ils permettent en général de choisir entre plusieurs entrelacements. Au minimum, ils permettent de désactiver l'entrelacement, d'activer un entrelacement de ligne, et d'autres entrelacements plus complexes, dont certains sont propriétaires et tenus secrets par le fabricant. L'entrelacement est géré juste avant le séquenceur mémoire, dans un '''circuit d'entrelacement'''. Ce dernier intervertit certains bits de l'adresse lors des accès mémoires. [[File:Controleur mémoire d'une SDRAM avec entrelacement.png|centre|vignette|upright=2.5|Contrôleur mémoire d'une SDRAM avec entrelacement.]] Les contrôleurs de SDRAM précédents sont assez basiques et ne représentent pas les contrôleurs les plus évolués. Ils étaient utilisés sur les anciens PC, à une époque où ils étaient encore sur la carte mère du processeur. Mais de nos jours, le contrôleur mémoire est intégré au processeur. Et il incorpore de nombreuses optimisations afin de gagner en performances. Malheureusement, ces optimisations sont elles-mêmes dépendantes des optimisations intégrées dans le processeur, ce qui fait qu'on ne peut pas en parler ici. Elles demandent que le contrôleur mémoire reçoive plusieurs accès mémoire simultanés, ce qui n'a aucun sens à ce stade du cours. Aussi, nous allons les passer sous silence pour le moment. Nous les verrons dans le chapitre sur le parallélisme mémoire. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=La désambiguïsation mémoire | prevText=La désambiguïsation mémoire | next=Les processeurs superscalaires in-order | nextText=Les processeurs superscalaires in-order }} </noinclude> b8cwt5ez7sk5bgd5il1uq9745tlsyxe Fonctionnement d'un ordinateur/Les processeurs superscalaires in-order 0 65956 774159 773901 2026-10-04T17:06:32Z Mewtow 31375 /* Les CPU superscalaires mélangent les trois techniques */ 774159 wikitext text/x-wiki Les processeurs vus auparavant ne peuvent émettre au maximum qu'une instruction par cycle d'horloge. Ils peuvent avoir plusieurs instructions qui s'exécutent en même temps, dans des unités de calcul séparées. C'est le cas dès qu'une instruction multi-cycles s'exécute. Par contre, ils ne peuvent démarrer qu'une seule instruction par cycle. 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'''. Un processeur à émission multiple charge plusieurs instructions en même temps, les décode en parallèle, puis les émet en même temps sur des unités de calculs séparées. Pour cela, 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. En général, pour un processeur à émission multiple capable d'émettre N instructions en parallèle, on parle de '''processeur à N voies''', ou encore un ''processeur à N pipelines''. [[File:Superscalarpipeline.svg|centre|vignette|upright=1.5|Pipeline RISC classique à cinq étages sur un processeur superscalaire. On voit bien que plusieurs instructions sont chargées en même temps.]] 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 qu'ils ont un jeu d'instruction spécialisé qui expose le parallélisme d'instruction directement au niveau du jeu d'instructions, là où les processeurs superscalaires restent des processeurs au jeu d'instruction normal. La différence principale entre processeur VLIW et superscalaire 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. Nous allons voir uniquement les processeurs superscalaires dans ce chapitre. Et plus précisément ceux sans exécution dans le désordre. Un processeur superscalaire peut utiliser l'exécution dans le désordre, mais ce n'est pas une obligation. Historiquement, les premiers processeurs superscalaires étaient des processeurs ''in-order'' (ce qui veut dire qu'ils n'utilisaient pas d'exécution dans le désordre). Le meilleur exemple est celui du Pentium 1 et 2 d'Intel. Ils étaient tous deux des processeurs superscalaires ''in-order''. Dans ce chapitre, nous allons voir uniquement les processeurs superscalaires ''in-order'' pour simplifier les explications. Il faut cependant garder à l'esprit que les explications qui vont suivre marchent aussi pour les processeurs superscalaires ''out of order'', sauf pour quelques exceptions. Aussi, ce chapitre servira d'introduction au chapitre suivant. ==Le nombre de voies d'un processeur superscalaire== En général, les processeurs superscalaires ''in-order'' 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 triple émission permettent eux d’émettre trois instructions à la fois, mais ils sont plus rares. A l'opposé, les CPU superscalaires les plus puissants peuvent émettre jusqu'à 8-10 instructions simultanément. En 2026, les CPU superscalaires à exécution dans le désordre peuvent monter jusqu’à 10 µops émises en même temps, ce qui est énorme ! Et cela nous amène à faire une distinction entre les processeurs superscalaires dits '''étroits''' et ceux dits '''larges'''. Dans ce wikilivre, on part du principe que les processeurs superscalaires étroits émettent jusqu'à 4-5 µops à la fois, alors que les larges peuvent en émettre plus, l'intervalle entre les deux est une zone grise. Le nombre exact n'est pas fixé dans le marbre, la distinction est volontairement vague. Nous parlerons de la '''largeur d'un CPU superscalaire''' pour parler du nombre de voies, du nombre de µops émises en même temps. Plus un CPU superscalaire est large, plus il est performant ''toutes choses égales par ailleurs''. Mais dans les faits, vous pouvez oublier la partie en italique. Élargir un CPU superscalaire demande de rajouter beaucoup de circuits. Et ce cout en circuit aura un impact sur la performance. À budget en transistors limité, il y a un compromis entre élargir un CPU et rajouter du cache ou la taille des ''schedulers''/ROB et autres. ===Les contraintes d’appariement=== Prenons un processeur double émission, capable d'émettre deux instructions simultanément. Émettre deux instructions en même temps n'est pas toujours possible. Par exemple, si deux instructions sont dépendantes, on ne peut pas les émettre en même temps. Mais oublions un instant ces histoires de dépendances de données, et partons du principe que le CPU ait affaire à deux instructions indépendantes, une paire idéale. Un CPU double émission "parfait" pourrait exécuter n'importe quelle paire d'instruction. Par exemple, il pourrait émettre deux multiplications consécutives, deux lectures consécutives, une lecture et une écriture en même temps, etc. En pratique, les processeurs qui font ça sont rare. Implémenter un CPU double émission "parfait" a un cout en circuit conséquent. Par exemple, pour émettre deux multiplications en même temps, il faudrait deux multiplieurs séparés. Idem pour exécuter deux accès mémoire simultanés : cela demande d'avoir deux unités mémoire et un cache multiport. La totalité des CPU double émission se débrouille avec un seul circuit multiplieur et une seule unité mémoire. Et cela entraine l'apparition de dépendances structurelles, qui font que certaines paires d'instructions sont impossibles. Par exemple, impossible d'émettre deux multiplications en même temps avec un seul circuit multiplieur. Mais émettre une multiplication en parallèle d'une addition est parfaitement possible. Il y a donc des paires d'instructions autorisées et des paires interdites. Pour le dire autrement, il y a des '''contraintes d'appariement''' sur les instructions à émettre en même temps. Ces contraintes d’appariement ont un impact sur les performances, mais celui-ci peut être grandement mitigé. Les programmes informatiques utilisent en pratique beaucoup plus d'opérations entières de base que de multiplication, branchements et accès mémoire. Il y a environ 5 opérations entières de base pour une multiplication entière. En pratique, il est rare de devoir exécuter deux multiplications à la fois. Idem pour les décalages et rotations, qui sont assez éloignés les uns des autres. Deux branchements consécutifs sont quant à eux plus fréquents. Deux accès mémoire consécutifs est assez rare, mais ils restent assez proches les uns des autres malgré tout. La conséquence est que certaines paires d'instructions sont privilégiées par rapport au reste. Les paires regroupant deux calculs basiques (additions, soustractions, ...) sont systématiquement supportées, car dupliquer des ALU entières est facile. Les paires regroupant un calcul et une autre instruction sont elles aussi systématiquement supportées. Par contre, les paires avec deux branchements, deux accès mémoire, deux décalages/rotations, ou deux multiplications/divisions sont fréquemment interdites. Seuls les CPU superscalaires larges peuvent les permettre, et encore : sous conditions. Le résultat est que le processeur peut se débrouiller avec une seule unité mémoire et une unité de branchement. Décrire les contraintes d’appariement demande de dire quelles combinaisons d’instructions sont autorisées ou interdites. Et pour cela, nous allons introduire les abréviations suivantes : * INT est un raccourci pour parler d'une opération entière simple, qu'une ALU entière peut exécuter. * MUL correspond à une opération entière complexe, typiquement une multiplication, éventuellement à une division. * FLOAT désigne une opération flottante, peut importe laquelle. * MEM désigne un accès mémoire, à savoir une instruction ou µop mémoire. * BRANCH désigne une instruction de branchement. Cette terminologie permettra de définir des paires d'instructions, qu'elles soient possibles ou interdites. Par exemple, l'expression INT + FLOAT désigne une paire d'instruction regroupant une instruction entière et une instruction flottante, peu importe l'ordre des deux instructions. Il en sera de même pour les triplets, quadruplets, ou plus. Par exemple, l'expression INT + INT + FLOAT + MUL désigne un quadruplet d'instructions regroupant : deux instructions entières, une instruction flottante, et une multiplication entière. J'utiliserais aussi des parenthèses pour regrouper certaines combinaisons. Par exemple, l'expression INT + (MUL /FLOAT) veut parler d'une paire d'instruction où la première est une opération entière basique, la seconde est : soit une multiplication, soit une opération flottante. ===L'historique simplifié de la superscalairité=== L'histoire des CPU superscalaires a commencé dès 1960, mais est resté un projet de recherche pendant plusieurs décennies. De nombreux designs ont été conçus, mais abandonnés avant la production, comme l'ACS-1 d'IBM. Les années 1980 ont vu l'arrivée de prototypes de processeurs VLIW, mais qui n'étaient pas superscalaires. Par exemple, l'Apollo DN10000 et l'Intel i860 pouvaient émettre deux instructions à la fois, sous conditions, mais demandaient que le compilateur fasse une bonne partie du travail. Les premiers CPU superscalaires commercialisés sont sortis en 1989. Deux CPUs superscalaires sont sortis cette année-là : le RS/6000 d'IBM et l'i960CA d'Intel. Puis, la compétition s'est aussi lancée dans la bataille. Les CPU commercialisés avant l'année 1993 étaient à double émission, à l'exception du SuperSPARC qui utilisait la triple émission. Les modèles après 1993 hésitaient entre double et quadruple émission. Dans le tableau ci-dessous, les processeurs quadruple émission sont en bleu, les triple émission sont en rouge, les autres ne sont pas en couleur. {|class="wikitable" |- ! !! IBM !! HP PA-RISC !! SPARC !! Intel!! Alpha !! Autres |- ! 1989 | RS/6000 || || || i960CA || || |- ! 1990 | || || || || || |- ! 1991 | || || || || || Motorola 88110 |- ! 1992 | RSC || PA 7100 || class="f_rouge | SuperSPARC || || 21064 || |- ! 1993 | PPC 601 || || HyperSPARC || Pentium || || |- ! 1994 | class="f_bleu | POWER 2 || PA 7200 || || || class="f_bleu | 21164 || |- ! 1995 | 603/604 || class="f_bleu | PA 8000 || class="f_bleu | UltraSPARC 1 || Microarchitecture P6 (+ concurrence AMD) || || class="f_bleu | MIPS R1000 |- ! 1996 | class="f_bleu | 620 || || || || class="f_bleu | 21264 || |- ! ... || ... || ... || ... || ... || ... || ... |} ==Le ''front-end'' des processeurs superscalaires== Un processeur superscalaire doit charger, décoder, et exécuter plusieurs instructions par cycle. Par exemple, un processeur triple émission doit charger 3 instructions lors d'un premier cycle, puis les décoder au cycle suivant, et les exécuter au cycle suivant. Il travaille donc pas paquets de 3 instructions, dans cet exemple. Si c'est un processeur superscalaire ''in-order'', cela donne l'impression que le pipeline dispose de plusieurs '''pipelines séparés'''. Intuitivement, charger, décoder et exécuter plusieurs instructions à la fois demande de dupliquer des circuits. Par exemple, il faut dupliquer les décodeurs pour décoder plusieurs instructions. Les unités de calcul sont aussi dupliquées, sauf sur certains processeurs qui arrivent à ruser, mais laissons cela de côté pour plus tard. Mais d'autres circuits sont simplement modifiés, comme l'unité de chargement ou l'unité d'émission. Voyons ce qu'il en est dans le détail. {|class="wikitable" |- ! rowspan="2" | Processeur sans émission multiple | rowspan="2" | Chargement | rowspan="2" | Décodage | rowspan="2" | Émission | Exécution / ALU | rowspan="2" | ''Commit''/ROB |- | Exécution / ALU |- ! rowspan="4" | Processeur superscalaire | rowspan="4" | Chargement | rowspan="2" | Décodage | rowspan="4" | Émission | Exécution / ALU | rowspan="4" | ''Commit''/ROB |- | Exécution / ALU |- | rowspan="2" | Décodage | Exécution / ALU |- | Exécution / ALU |} Nous allons d'abord voir ce qu'il en est pour le ''front-end'', à savoir la partie du CPU qui s'occupe de charger et décoder les instructions. Elle regroupe l'unité de chargement, les décodeurs, l'unité de prédiction de branchement, la file de µops, la file d'instruction, et divers caches. En soi, les changements portent surtout sur l'unité de chargement et les décodeurs. ===Le chargement de plusieurs instructions consécutives=== Un processeur superscalaire charge plusieurs instructions en même temps, ''en tenant compte de la présence de branchements''. La partie en italique est importante et elle a une conséquence très forte : la manière de faire est fortement différente entre un CPU superscalaire étroit et large. Un CPU superscalaire étroit fait au plus simple : il charge un bloc de 2, 3, 4 instructions consécutives en mémoire RAM. Il faut alors doubler/tripler/quadrupler la taille du bus connecté au cache d'instruction. Par exemple, pour charger quatre instructions de 32 bits chacune, il suffit de lire un bloc de 128 bits dans le cache d'instruction. Un bloc de 8, 16, 32 octets est donc lu depuis le cache et est ensuite découpé en instructions, envoyées chacun à un décodeur. Charger plusieurs instructions consécutives ne marche bien qu'en absence de branchements. La présence de branchements casse cette dynamique, et il faut gérer leur présence. * Sans prédiction de branchement, toutes les instructions d'un bloc sont décodées et exécutées en même temps, même s'il y a un branchement dans le tas. * Avec prédiction de branchement, les instructions situées après le branchement ne sont pas décodées ni exécutées. L'unité de chargement coupe le bloc chargé au niveau du premier branchement pris, remplit les vides avec des NOP, avant d'envoyer le tout à l'unité de décodage. Les instructions à l'adresse de destination seront chargées au cycle suivant, en chargeant un nouveau bloc. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un autre défaut est que sur le CPU charge un bloc de 128 bits à la fois, les instructions doivent être alignées sur 128 bits. Et on peut généraliser à n'importe quelle taille de bloc. Si les instructions ne sont pas alignées, il faut gérer la situation. Il arrive qu'une instruction soit à cheval sur deux blocs, avec un morceau dans le premier bloc, un autre dans le second. Dans ce cas, l'instruction est chargée en deux fois, et des circuits doivent gérer la situation pour recoller les morceaux. Sur les CPU avec des instructions de taille fixe, le problème ne survient que rarement. Mais il est fréquent avec des instructions de taille variable. Un autre défaut est lié aux branchements. Imaginez qu'un branchement s'exécute et branche vers une instruction de destination. Rien n'indique que l'instruction de destination sera au tout début d'un bloc. Si ça se trouve, elle sera en troisième position d'un bloc de 4 instructions. Une solution simple charge le bloc en respectant l'alignement, et les deux premières instructions du bloc ne sont pas à exécuter. Il y a donc une petite perte de performance. Certains CPU, comme l'IBM RS/6000, ont la machinerie charger un bloc de N instructions même s'il n'est pas aligné. La machinerie en question se trouve dans le cache d'instruction et est tout sauf simple à expliquer, ce qui fait que je me permets de la passer sous silence. Un détail, cependant : elle ne permet pas de charger des blocs qui sont à cheval sur deux lignes de cache. ===Le décodage parallèle des instructions=== Pour le décodage des instructions, plusieurs instructions sont décodées en même temps. On parle alors de '''décodage parallèle'''. Pour ce faire, un processeur superscalaire contient plusieurs décodeurs, chacun pouvant décoder une instruction en parallèle des autres. Prenons par exemple un processeur superscalaire à 4 voies : le décodage se fera dans quatre décodeurs séparés, qui fonctionneront en parallèle. Cependant, cette implémentation ne marche bien que pour les processeurs RISC, les processeurs CISC devant faire avec un microcode gourmand en circuits. Les processeurs CISC utilisent des décodeurs hybrides, avec un microcode qui complémente un décodeur câblé. Dupliquer le microcode aurait un cout en transistors trop important, ce qui fait que seuls les décodeurs câblés sont dupliqués. Les CPU CISC superscalaires disposent donc de plusieurs décodeurs simples, capables de décoder les instructions les plus courantes, avec un seul microcode. La conséquence est qu'il n'est pas possible de décoder deux instructions microcodées en même temps. Par contre, il reste possible de décoder plusieurs instructions non-microcodées ou une instruction microcodée couplée à une instruction non-microcodée. Vu qu'il est rare que deux instructions microcodées se suivent dans un programme, le cout en performance est extrêmement mineur. Il faut noter qu'une instruction correspond à une ou plusieurs µops. Il est donc possible de charger 5 instructions et de se retrouver avec 8 µops en sortie des décodeurs. Et c'est la source d'une distinction très importante. Plus haut, j'ai parlé de processeurs superscalaires à N voies. La terminologie peut avoir deux interprétations. La première est que le processeur charge/décode N instructions à la fois. Une seconde est qu'il est capable d'exécuter/émettre N µops en même temps. Les deux ne sont pas équivalentes, vu qu'un processeur superscalaire peut, par exemple, charger/décoder N instructions et émettre/exécuter N+2 µops. La distinction est particulièrement importante sur les processeurs superscalaires larges. Certains d'entre eux sont capables de charger 5 instructions et d'en émettre le double ! Un exemple classique était les processeurs Athlon d'AMD, qui pouvaient décoder deux instructions par cycle, mais émettre 4 µops par cycle. Ce n'était pas un problème, car beaucoup d'instructions, même basiques, étaient décodées en 2 µops. ===L'émission parallèle des instructions=== La superscalarité modifie en profondeur l'unité d'émission, notamment au niveau de son interface. Avant, elle recevait une micro-opération sur son entrée, et fournissait la micro-opération émise sur une sortie. Et cela vaut aussi bien pour une unité d'émission simple, un ''scoreboard'', un ''scheduler'' 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. Dans ce qui suit, nous ne parlerons pas d'entrée et de sortie, mais de '''ports de décodage''' et de '''ports d'émission'''. Les ports de décodage reçoivent des micro-opérations provenant de l'unité de décodage, les ports d'émissions sont pour les micro-opérations émises. Les ports d'émission injectent les micro-opérations émises dans le pipeline, elles sont propagées au banc de registre, aux ALUs, etc. Un processeur superscalaire a forcément plusieurs ports d'émission (et de décodage). [[File:Ports de décodage et d'émission.png|centre|vignette|upright=2|Ports de décodage et d'émission]] Un détail important est que l'unité d'émission peut avoir plus de ports d'émission que de ports de décodage. Et n'allez pas croire qu'un surplus de ports d'émission est du gâchis. Sur les processeurs avec exécution dans le désordre, ce surplus peut être utilisé pour émettre une µops sur chaque port d'émission par cycle d'horloge, ce qui donne un gain de performance appréciable. Ceci dit, les processeurs sans exécution dans le désordre utilisent aussi des ports d'émission en surplus. La raison sera expliquée dans quelques sections, mais nous pouvons dire que cela simplifie la conception du processeur. Si l'unité d'émission a un surplus de ports d'émission, on doit alors distinguer sa ''largeur de décodage'' et sa ''largeur d'émission''. La '''largeur de décodage''' est le nombre de µops fournies par l'unité de décodage par cycle d'horloge, la '''largeur d'émission''' est le nombre maximal de µops émises par cycle d'horloge. La largeur d'émission est égale ou supérieure à la largeur de décodage, tout dépend du processeur. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] Si l'interface de l'unité d'émission change, l'intérieur est tout autant modifié. Sur un CPU non-superscalaire, l'unité d'émission reçoit une µop "candidate à l'émission", et détecte les dépendances entre avec les µops en vol, en cours d'exécution dans le pipeline. Et c'est toujours le cas sur les processeurs superscalaires : l'unité d'émission détecte les dépendances entre les µops candidates et les µops en vol. Mais avec la siperscalarité, elle doit en plus tester les dépendances entre les µops candidates. Si l'unité d'émission est un ''scoreboard'', il doit détecter les dépendances entre instructions émises simultanément et les sérialiser si besoin. Avec un ''scheduler'', il n'y a pour ainsi dire rien à faire, vu que les dépendances sont éliminées par le renommage de registre et que les signaux de réveil gèrent les dépendances RAW. C'est la raison pour laquelle les processeurs superscalaires modernes n'utilisent pas de ''scoreboard''. ==Le ''back-end'' des processeurs superscalaires== Posons-nous cette question : de combien d'unités un processeur superscalaire a besoin ? Les unités en question ne sont pas nécessairement des unités de calcul, l'unité mémoire et l'unité de branchement comptent aussi. La réponse dépend du nombre de voies. Si le ''front-end'' charge et décode un paquet de N instructions, exécuter ces N instructions simultanément demandera là aussi au moins N unités de calcul. Notez le terme : ''au moins'', car c'est là tout l'enjeu de cette section. La réponse dépend aussi des contraintes d'appariement. Pour un CPU superscalaire "parfait", sans contraintes d’appariement, toutes les unités doivent être dupliquées, y compris l'unité mémoire. Un processeur à double émission parfaite devrait dupliquer l'ALU entière, le ''barrel shifter'', le circuit multiplieur, la FPU, l'unité mémoire, et l'unité de branchement, ce qui ferait passer de 6 unités à 12. Le cout en circuit est énorme, même pour de la double émission ! Et pour un CPU à N pipelines, le cout en circuit encore plus prohibitif ! En pratique, les processeurs superscalaires qui font ça sont très rares. La majeure partie des processeurs superscalaires utilisent d'autres techniques : le partitionnement, la duplication partielle des ALUs, la duplication des autres unités. ===Le partitionnement des unités fonctionnelles=== Les CPU superscalaires les plus simples ne dupliquent pas leurs unités de calcul, ils se contentent d'exploiter les unités existantes. Et pour expliquer comment, nous allons partir de l'exemple d'un processeur non-superscalaire contient une ALU entière, une FPU, une unité mémoire et une unité de branchement. Les quatre unités peuvent fonctionner en parallèle, à savoir qu'une unité peut exécuter une instruction à elle toute seule, sans interagir avec les autres. Cela suffit pour créer un processeur quadruple émission, qui peut émettre en même temps : une opération entière, une opération flottante, un branchement, un accès mémoire. Le principe marche aussi sur un processeur avec moins de 4 unités, par exemple sans FPU et/ou sans unité de branchement, ou avec au contraire des unités en plus. L'idée générale est d’émettre une µops séparée dans chaque unité fonctionnelle. Utiliser ainsi les unités existantes, pour rendre un CPU superscalaire, s'appelle le '''partitionnement superscalaire'''. Il consiste à attribuer un port d'émission à chaque unité existante. Le terme n'est pas beaucoup utilisé en dehors de quelques vieux articles académiques. Le partitionnement est fortement dépendant du jeu d'instruction, car il demande de bien séparer instructions entières, flottantes, branchements et instructions mémoire. Et autant c'est bien le cas sur les CPU RISC, autant les CPU CISC ne sont pas dans ce cas. Les CPU CISC ont des instructions ''load-op'', qui effectuent une instruction arithmétique et une µop mémoire. Et ces instructions sont très compliquées à implémenter avec le partitionnement. Et au-delà de ça, les CPU CISC ont beaucoup d'instructions qui sont décodées en plusieurs µops, à exécuter dans un ordre bien précis. On peut pas émettre les µops en question simultanément. Le partitionnement a un cout en circuit très limité, pour un gain en performance lui aussi limité. Pour reprendre le CPU de l'exemple précédent, il est rare que l'on ait à exécuter ces quatre instructions en même temps. En pratique, les combinaisons fréquentes sont les combinaisons calcul + accès mémoire, calcul + branchement, calcul entier + calcul flottant. Utiliser une quadruple émission de ce type n'est donc pas une bonne idée, alors que la double émission parait plus adaptée. Avant de poursuivre, précisons que la FPU regroupe un additionneur flottant et un multiplieur flottant. Et ce fait peut être exploité pour approfondir le partitionnement. La '''double émission FADD/FMUL''' permet d'émettre une addition flottante en même temps qu'une multiplication flottante. Elle a été utilisée sur les processeurs x86, Alpha et SPARC. Les autres circuits de calcul flottant sont souvent regroupés avec l'additionneur. La division est parfois e même port que le multiplieur flottant, parfois sur celui de l'additionneur (quand elle est microcodée et exécutée en enchainant les soustractions). ===La duplication des ALU entières=== Le partitionnement est une technique élégante, simple, facile à implémenter et au cout en transistors modeste. Mais il faut avouer que cela ne donne pas des CPU superscalaires très efficaces. Le partitionnement permet d'émettre des paires, triplets, quadruplets de micro-opérations qui sont rares en pratique. Et les paires, triplets, quadruplets d'instructions courants ne sont pas supportés. Les contraintes d’appariement sont trop strictes. Les combinaisons les plus fréquentes en pratique sont des paires/triplets d'opérations entières, souvent des paires/triplets d'additions ou d'opérations entières simples. Il est donc primordial d'émettre plusieurs opérations entières simultanément. Le fait d'émettre et d'exécuter plusieurs opérations entières en même temps s'appelle l''''émission entière multiple'''. Pour exécuter la seconde/troisième opération entière, il faut dupliquer les ALU entières (ou réutiliser l'AGU comme ALU entière comme le fait l'Intel i960). Le processeur doit avoir deux ALU entières pour de la double émission entière, trois pour de triple émission entière, etc. De plus, il faut ajouter des ports au banc de registre, pour lire deux fois plus d'opérandes et décrire deux résultats. Et enfin, il faut rajouter un port d'émission pour pouvoir émettre une micro-opération entière en plus. [[File:Processeur non-superscalaire basique 03.png|centre|vignette|upright=2|Processeur non-superscalaire basique]] La tendance est de multiplier les ALU entières, mais pas le circuit multiplieur. Le cout en transistors est alors grandement réduit, au prix de l'apparition de dépendances structurelles. Le CPU peut émettre deux additions en même temps, ou une addition et une multiplication, mais pas deux multiplications. Il en est de même avec le ''barrel shifter'', qui n'est pas dupliqué. Il est donc possible d'émettre un décalage avec une opération de base, mais pas deux décalages en parallèle. Mais ces contraintes d’appariement ne sont pas un problème, car il est rare de devoir émettre deux multiplications/décalages en même temps. La plupart des programmes sont majoritairement remplis d'additions, avec des multiplications assez rares et des décalages qui le sont encore plus. La moyenne est proche d'une multiplication pour 4/5 additions. Vu que des multiplications consécutives sont très rares, dupliquer le multiplieur est presque inutile. Disposer de plusieurs circuits multiplieurs ou de plusieurs ''barrel shifters'' serait donc un cout en circuits qui ne servirait que rarement et n'en vaut pas la chandelle. Il faut noter que sur les CPU 32 bits, les multiplications peuvent être exécutées dans la FPU ou dans un multiplieur séparé, tout dépend de si le CPU exécute les multiplications dans la FPU ou pas. {|class="wikitable" |- ! Voie d'émission n°1 !! Voie d'émission n°2 !! Voie d'émission n°3 |- | ALU entière || ALU entière || FPU |} Reste à connecter les unités de calcul à l'unité d'émission (indirectement, car il y a des étages de pipeline entre les deux). Une implémentation basique utilisée un port d'émission par unité. Prenons l'exemple d'un CPU superscalaire avec 2 ALU entières, un multiplieur entier, un ''barrel shifter'' une FPU, et une unité mémoire. Vu qu'il y a 6 unités, cela fait donc 6 ports d'émission. C'est beaucoup, l'unité d'émission est très complexe. Suffisamment complexe pour se demander s'ils n'existerait pas des optimisations qui permettraient d'en réduire le nombre sans trop réduire les performances, ni le nombre d'ALU. Et cela nous amène à la section suivante. ===Le partage des ports d'émission=== La présence d'un circuit multiplieur et d'un ''barrel shifter'' pose des problèmes pour le partitionnement. Il est possible d'avoir un port d'émission dédié au multiplieur, et un autre au ''barrel shifter'', qui sont séparés de ceux pour l'ALU entière. Il est alors possible d'émettre une opération entière, une multiplication et un décalage, simultanément. Nous parlerons de '''partitionnement entier''' pour désigner cette possibilité. [[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]] Le partitionnement entier est cependant très rare, car le gain en performance serait trop faible au regard des couts en circuit. Quelques processeurs historiques l'utilisaient, mais ce n'est plus le cas de nos jours. La raison est que les multiplications et les décalages sont assez rares dans le code, ce qui fait que le port d'émission associés sont sous-utilisés. Et un port d'émission a un cout en circuit assez important. Et c'est sans compter qu'ajouter des ports sur le banc de registres juste pour un multiplieur sous-utilisé n'en vaut pas la peine. L'idéal serait de trouver un moyen de réduire le nombre de ports d'émissions, sans réduire le nombre d'unités de calcul. [[File:Emission multiple des opérations entières, implémentation naive.png|vignette|Émission multiple des opérations entières, implémentation naive]] La solution consiste à '''partager les ports d'émission''', à savoir connecter un port d'émission à plusieurs unités de calcul. L'exemple classique est justement lié aux circuits multiplieur et ''barrel shifters''. L'idée est que le multiplieur et le ''barrel shifter'' sont mis sur le même port d'émission qu'une ALU entière. Prenons par exemple le processeur à double émission entière illustré ci-contre. Un port d'émission est relié à une ALU entière seule, et l'autre port d'émission alimente la seconde ALU entière et le multiplieur. La conséquence est que le processeur peut émettre deux opérations entières, dont une multiplication. Si on avait utilisé un port d'émission dédié au multiplieur, il aurait été possible d'émettre deux opérations entières et une multiplication. On perd donc une voie, mais on gagne en flexibilité et on économise des circuits. Notons que ce partage signifie aussi que le multiplieur partage les ports sur le banc de registre, ce qui fait une double économie. [[File:Emission multiple des opérations entières, implémentation courante.png|centre|vignette|upright=1.5|Émission multiple des opérations entières, implémentation courante]] En général, le ''barrel shifter'' et le multiplieur sont placés sur deux voies d'émission séparés, ce qui permet d'émettre un décalage et une multiplication en même temps. Les mettre sur le même port ne le permettrait pas. {|class="wikitable" |- ! Voie d'émission n°1 !! Voie d'émission n°2 !! Voie d'émission n°3 |- | * ALU entière * Multiplieur | * ALU entière * Barrel shifter | FPU |- | * Additions, soustractions, opérations bit à bit, etc. * Multiplication | * Additions, soustractions, opérations bit à bit, etc. * Décalages et rotations | Opération flottante |} Terminons concernant les multiplieurs avec une dernière subtilité. Pour rappel, les processeurs modernes intègrent en réalité deux multiplieurs différents. Le résultat d'une multiplication est très large et prend deux registres. Par exemple, une multiplication de deux opérandes 64 bits donne un résultat sur 128 bits, ce qui ne tient pas dans un registre, mais en prend deux. L'instruction de multiplication classique ne calcule que les 64 bits de poids faible. mais elle est complétée par d'autres instructions de multiplication qui peuvent faire le calcul complet. Pour résoudre le problème, les processeurs modernes intègrent deux instructions de multiplications : une qui calcule les bits de poids faible du résultat, une autre qui calcule les bits de poids fort. Et ces deux instructions sont associées à deux multiplieurs séparés, aux circuits différents. Le multiplieur qui calcule les bits de poids faible du résultat est appelé le ''low multiplier'', l'autre est appelé le ''high multiplier''. Le second multiplieur est souvent placé sur un second port d'émission, différent du premier multiplieur, partagé avec une ALU. L’avantage de mettre les deux multiplieurs sur deux ports d'émission différents est qu'une multiplication complète peut être réalisée en utilisant les deux multiplieurs en parallèle. Les deux sous-multiplications sont émises en même temps, exécutées en même temps. pas besoin d'exécuter l'une puis la seconde, d'où un gain performance appréciable. Nous venons de voir comment partager un port d'émission entre une ALU et un multiplieur. Mais au-delà de cet exemple, le partage des ports d'émission peut appliquer à n'importe quelle unité. Prenons l'exemple d'un CPU superscalaire avec 4 ALU entières, une FPU, et une unité mémoire. Vu qu'il y a 6 unités, cela fait donc 6 ports d'émission. Cependant, il est rare qu'un bloc de 6 instructions contienne exactement 4 opérations entières, une opération flottante, et un accès mémoire. Beaucoup de ports d'émission seraient sous-utilisés. La solution est de partager des ports d'émission, pour passer à la quadruple émission. Le CPU peut alors répartir 4 instructions sur les ports d'émission adéquats, à chaque cycle. Le CPU a donc 4 ports de décodage et 6 ports d'émission. Une implémentation classique permet n'importe quelle combinaison de 4 instructions compatibles avec les 6 unités. Par exemple, il devient possible d'émettre simultanément 4 additions, la combinaison 2 * INT + FLOAT + MEM, 3 * INT + MEM, et bien d'autres. L'exemple relie une ALU entière, la FPU et l'unité mémoire sur le même port. Le CPU a alors quatre ports d'émission, qui ne sont pas "identiques". Trois d'entre eux sont reliés à une ALU entière chacun, rien d'autre. Le quatrième est lui relié à une ALU entière, la FPU et l'unité mémoire. On économise alors deux ports d'émission, mais cela entraine l'apparition de contraintes d’appariement. Le CPU peut émettre 3 opérations entières + n'importe quelle autre opération. Mais les autres combinaisons sont interdites. Le CPU peut émettre 6 opérations entières simultanément, grâce à 6 ALU entières. Les accès mémoire ou opérations flottantes ne viennent pas en plus, mais en remplacement des opérations entières. Regrouper plusieurs ALU sur un même port d'émission est donc à l'origine de dépendances structurelles, de nouvelles contraintes d’appariement. Dans certaines situations de '''''port contention''''', on a assez d'ALU pour exécuter N micro-opérations, mais la répartition des ALUs sur les ports l'empêche. Impossible d'émettre deux micro-opérations sur deux ALU si elles sont sur le même port d'émission. On peut réduire les cas de ''port contention'' en répartissant correctement les ALU sur les ports d'émission, mais sans jamais les supprimer complétement. Un exemple est celui du PowerPC 604. Il implémentait la quadruple émission, alors qu'il avait 5 unités de calcul. Mais deux unités partageaient un même port d'émission. Dans le détail, il avait deux ALU entières, un circuit multiplieur/diviseur, une FPU, une unité mémoire. La FPU et le multiplieur étaient sur le même port d'émission. En conséquence, le CPU pouvait émettre seulement les combinaisons INT + INT + MEM + (MUL/FLOAT). : Il est intéressant de voir l'exemple d'un processeur superscalaire parfait, sans contraintes d'appariement, à N voies. Pour cela, on part d'un processeur non-superscalaire, avec M unités de calcul. Un tel processeur peut s'implémenter avec N ports d'émission, chacun relié à M unités de calcul possibles, ce qui fait que les M unités de calcul sont dupliquées en N exemplaires. Si on prend par exemple un CPU non-superscalaire avec une ALU, une FPU, une unité mémoire et une unité de branchement, un port d'émission sera relié à une ALU, une FPU, une unité mémoire et une unité de branchement. Dupliquer les ALU entière est une première étape, mais il est aussi possible de dupliquer d'autres unités, comme l'unité mémoire ou l'unité de branchement. La rentabilité en performance dépend surtout de si cela autorise des paires d'instructions fréquentes. Le cout en circuit n'est aussi pas le même. Sur les CPU superscalaires ''in-order'', seuls les ALU et FPU sont dupliquées. ===Les CPU superscalaires mélangent les trois techniques=== Les CPU superscalaires utilisent toutes les techniques à la fois : partitionnement, duplication de l'ALU entière, duplication des autres unités fonctionnelles. Chaque processeur utilise ces trois stratégies à sa sauce, en fonction de son budget en transistor. Les quatre techniques ont cependant un cout en circuit différent, et donnent des gains de performances eux aussi forts différents. * Le '''partitionnement superscalaire''' a un cout en circuit modéré, car on n'a pas besoin de dupliquer d'ALU. * La '''duplication des ALU entières''' est la technique la plus importante : elle a un cout en circuit modéré, et donne des gains en performance impressionnants. * Le '''partage des ports d'émission''' réduit le cout en circuit du CPU, pour des pertes de performances mineures si bien fait. L'usage du partitionnement seul, sans duplication, était surtout le fait des processeurs commercialisés entre les années 1989 et 1993. De nos jours, les processeurs utilisent un mix différent d'unités, et leur partitionnement est différent. Ils tendent à s'adapter au mieux aux besoins des programmes exécutés. D'après diverses estimations, un programme informatique exécute environ 40% de son code dans des instructions arithmétiques, un autre 40% dans les accès mémoire, et un le reste pour... le reste. L'idéal est donc de pouvoir émettre autant de µops mémoire que de µops entières, à chaque cycle. Dit autrement, la largeur du processeur doit pouvoir être répartie ''moitié opération - moitié mémoire''. Bien évidemment, le processeur peut gérer d'autres combinaisons. Pour s'adapter, les processeurs dupliquent d'abord les ALU entières, suivi par l'unité mémoire, et enfin par les autres unités. Pour s’adapter au mieux, un processeur à N voies doit disposer d'au moins N/2 ALU entières, l'idéal étant d'aller au-delà. Les CPU superscalaires historiques avaient 2 ALU pour de la quadruple émission, ce qui était très limite. Les CPU superscalaires modernes mettent le paquet niveau ALU entière. La plupart des CPU x86 modernes descendent rarement sous les 2/3, la plupart remplissent presque la totalité de la largeur du CPU. Pour donner un exemple, la micro-architecture Golden Cove d'Intel est un processeur à 6 voies, avec 5 ALU entières. Ensuite, les processeurs modernes dupliquent leurs unités mémoire. Par exemple, la micro-architecture ''Golden Cove'' d'Intel dispose de trois unités mémoire : deux spécialisées dans les écritures, trois dans les lectures. La micro-architecture ''Gracemont'' utilise la quintuple émission et peut émettre 4 µops entières et 4 µops mémoire. Notez que le ratio est équilibré entre opérations entières et mémoire, avec soit égalité, soit un avantage pour les ALU entières. Une autre régularité est que la largeur totale du processeur peut être occupée par des opérations entières/mémoire. Pour le dire autrement, sur un processeur quadruple émission, il pourra émettre un mix de 4 instructions entière/mémoire, avec zéro opération flottante. Idem avec des processeurs triples ou quintuple émission, voire plus. Si le processeur émet une opération flottante, elle remplace une instruction entière ou mémoire. Prenons l'exemple des processeurs AMD K6, sortis en 1997, juste avant les AMD Athlon. Ils avaient 10 unités de calcul, 6 ports d'émission, 4 ports de décodage. Voici exactement comment étaient répartie les unités de calcul sur les ports d'émission. C'était des processeurs quadruple émission, ce qui fait que la moitié de la largeur du processeur sert pour des opérations entières, et l'autre moitié pour les accès mémoire. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 ! |- | ALU entière || ALU entière || FPU || Unité de branchement || Unité LOAD || Unité STORE |- | || Multiplieur || || || || Unité de calcul d'adresse (instruction LEA) |- | || ''Barrel shifter'' || || || || Unité pour les instructions PUSH |} Les micro-architectures K7, K8 et K10, utilisées sur les anciens CPU AMD Athlon, sont un exemple moins représentatif. Ces micro-architectures utilisent le partage des ports d'émission au maximum. C'était des micro-architectures à triple émission, avec six ports d’émission. Il y avait trois ports d'émission pour les opérations flottantes, et trois ports d'émission pour les micro-opérations entières/mémoire. Il y avait trois ALU entières et trois unités mémoire, chaque ALU partageait un port d'émission avec une unité mémoire. Elle pouvait donc émettre soit trois opérations entières, soit trois µops mémoire, soit un mix des deux. En plus des mix avec les opérations flottantes et branchements. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 ! |- | ALU entière || ALU entière || ALU entière || FADD || FMUL || FPU (autres opérations) |- | Multiplieur || || || || || |- | LOAD/STORE || LOAD/STORE || LOAD/STORE || || || |} Les CPU superscalaires larges dupliquent les multiplieurs et/ou les ''barrel shifters'', et même les unités de branchement. La raison est que quand la largeur du CPU augmente, le processeur traite des paquets d'instructions de plus en plus gros. Par exemple, pour un CPU à 10 voies, cela veut dire émettre 10 instructions en même temps. Il est alors plus fréquent d'avoir deux multiplications, deux décalages, deux accès mémoire, deux branchements. Par exemple, la moyenne est d'une multiplication tous les 5 instructions. Les CPU superscalaires à 10 voies ont donc tendance à avoir deux multiplications par paquet d'instruction, en moyenne. Avoir deux multiplieurs est alors un gain en performance clair. Par exemple, la micro-architecture ''Golden Cove'' a deux ''barrel shifters''. C'est une architecture à 6 voies. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 !! Port d'émission n°7 !! Port d'émission n°8 !! Port d'émission n°9 !! Port d'émission n°10 |- | ALU entière || ALU entière || ALU entière || ALU entière || ALU entière || LOAD || LOAD || LOAD || STORE || STORE |- | ''Barrel shifter'' || Multiplieur || Multiplieur || ''Barrel shifter'' || || || || || || |- | Branchements || Diviseur || || Branchements || || || || || || |} ==Les µops mémoire sur les processeurs superscalaires== Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. Voyons ces deux cas dans le détail. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ==Les processeurs à double émission== Dans cette section, nous allons étudier la forme la plus basique de superscalarité : les processeurs à double émission. De tels processeurs n'utilisent généralement pas l'exécution dans le désordre, mais il y a quelques exceptions. Les premiers CPU à double émission sont assez représentatifs des CPU à double émission modernes. Il faut dire que se limiter à deux voies ne permet pas de faire beaucoup de choses. En général, les processeurs à double émission ne dupliquent pas l'unité mémoire. Cela ne servirait à rien de faire deux accès mémoire simultanés sur un processeur à double émission. Et il en est de même pour la FPU et l'unité de branchement. Ils se limitent à deux techniques : dupliquer l'ALU entière, le partitionnement. Quant au partage des ports d'émission, c'est là que les ennuis commencent. L'implémentation la plus simple de la double émission utilise deux ports d'émission. Elle donne l'illusion que le processeur dispose de '''deux pipelines séparés'''. Ils partent d'un processeur non-superscalaire, et ajoutent un second pipeline, en sortie de l'unité d'émission. Le second pipeline permet l'émission parallèle d'une seconde instruction. Le problème est que les deux ports d'émission sont reliés à un grand nombre d'unités de calcul. L'unité d'émission doit donc partager des ports d'émission, et c'est là que les subtilités commencent. Il faut répartir les unités au mieux pour éviter d'avoir trop de contraintes d’appariement problématiques. Le second pipeline n'est pas identique au pipeline originel, sur plusieurs points. Premièrement, il n'a pas exactement les mêmes circuits. Le second pipeline est généralement composé d'une unité de calcul, et permet d'exécuter une seconde opération de calcul, pas plus. En conséquence, les contraintes d'appariement assez fortes. Et il n'a pas forcément la même longueur, il peut avoir des étages en moins ou en plus. Il s'agit de la forme la plus simple de processeur superscalaire et nous allons leur consacrer cette section. Une autre implémentation utilise de la double émission, mais avec 3, 4, 5, ... ports d'émission. Le processeur ne donne plus l'impression d'avoir deux pipelines séparés, mais gagne en flexibilité. Les contraintes d'appariement sont plus faibles, moins nombreuses, moins problématiques. Mais le cout en circuit n'est pas négligeable. Voici le tableau chronologique disant quel CPU utilisait quelle technique. * Les CPU en vert ajoutaient un second pipeline pour les opérations flottantes. * Les CPU en rouge ajoutaient un second pipeline pour les opérations entières. * Les CPU en jaune utilisaient un ''partitionnement complet'' entre ALU, FPU et unité mémoire. * Les CPU sans couleur utilisaient un grand nombre d'unités, avec beaucoup de ports d'émission. * Les CPU barrés utilisent la triple ou quadruple émission et seront vu dans la suite. {|class="wikitable" |- ! !! IBM !! HP PA-RISC !! SPARC !! Intel!! Alpha !! Autres |- ! 1989 | class="f_vert | RS/6000 || || || class="f_rouge | i960CA || || |- ! 1990 | || || || || || |- ! 1991 | || || || || || Motorola 88110 |- ! 1992 | class="f_vert | RSC || class="f_vert | PA 7100 || <s>SuperSPARC</s> || || class="f_jaune | 21064 || |- ! 1993 | class="f_vert | PPC 601 || || class="f_jaune | HyperSPARC || class="f_rouge | Pentium || || |- ! 1994 | <s>POWER 2</s> || PA 7200 || || || <s>21164</s> || |} ===La double émission entière-flottante=== L'exemple le plus simple utilise un second pipeline pour les opérations flottantes, séparé du pipeline originel. L'ajout d'un pipeline flottant permet d'émettre une micro-opération entière en même temps qu'une micro-opération flottante, ce qui s'appelle la '''double émission entière-flottante'''. La micro-opération entière s'exécute dans l'ALU entière, la micro-opération flottante dans la FPU. Les exemples les plus notables sont le PA-RISC 7100, le POWER 1 (RIOS .& et .9) et le PowerPC 601. L'Apollo DN10000 et l'Intel i860 utilisaient aussi cette forme de double émission, mais étaient des prototypes de CPU VLIW. [[File:Double émission entière-flottante.png|centre|vignette|upright=2|Double émission entière-flottante]] Pour bien expliquer les choses, prenons le processeur illustré ci-dessous, avec deux ''back-end'' séparés : un pour la FPU, et un autre pour le reste. Les instructions entières et mémoire passent toutes par le ''back-end'' généraliste. [[File:CPU non-superscalaire basique, avec une FPU.png|centre|vignette|upright=1.5|CPU non-superscalaire basique, avec une FPU]] Les deux ''back-end'' sont des pipelines séparés, mais il faut que l'unité d'émission soit spécialement conçue pour en profiter. Un processeur simple émission peut démarrer une seule µop par cycle : soit la µop est envoyée au ''back-end'' flottant, soit elle est envoyée au ''back-end'' général. Avec la double émission entière-flottante, le processeur peut envoyer une µop dans chaque pipeline, à chaque cycle. Vu que la FPU et son banc de registre forment déjà un pipeline séparé, les seules modifications se font au niveau du ''front-end'', unité d'émission inclue. {| |- |[[File:CPU simple émission avec une FPU.png|centre|vignette|upright=1.5|CPU simple émission avec une FPU.]] |[[File:Double émission entière flottante avec un second pipeline.png|centre|vignette|upright=1.5|CPU avec un second pipeline flottant.]] |} Notez que le terme double émission ''entière''-flottante est un peu trompeur. En réalité, la technique permet d'émettre une instruction flottante avec n'importe quelle autre instruction non-flottante, avec des restrictions pour les branchements. Il est par exemple possible d'exécuter un calcul flottant en parallèle d'un accès mémoire. Sur les CPU 32 bits, il est possible de faire les multiplications dans la FPU. Pour rappel, la FPU contient un multiplieur entier 53-54 bits, pour multiplier les mantisses de deux flottants 64 bits, qu'on peut utiliser pour faire les multiplications entières 32 bits. Cela permet d'émettre une multiplication en même temps qu'une opération entière dans l'ALU. Ainsi, le CPU pouvait émettre deux instructions : une opération entière de base, avec soit une multiplication, soit une opération flottante. En clair, une émission de type INT + (MUL /FLOAT). Par contre, on perd la possibilité d'émettre une multiplication entière avec une opération flottante, mais c'était un compromis qui en valait la peine. Le CPU PA-RISC 7100 implémentait cette optimisation. Il s'agissait d'un CPU double émission entière-flottante, qui utilisait donc un partitionnement assez simple. Le combo double émission entière-flottante + multiplication dans la FPU permettait ici de gagner en performance, pour un cout en matériel très limité. D’autant plus limité que le jeu d'instruction prévoyait le reconditionnement de la FPU. Les multiplications ne lisaient pas leurs opérandes dans les registres entiers, mais dans les registres flottants. ===La double émission entière=== Une autre possibilité ajoute un second pipeline, capable d'exécuter des opérations entières simples, appelé le pipeline entier. Le processeur supporte alors la '''double émission entière''', à savoir qu'ils peuvent émettre une opération entière en plus d'une autre instruction. L'opération entière en question est une opération simple, comme une addition, une soustraction, une opération bit à bit, une comparaison, etc. Un exemple de CPU double émission de ce type est celui du Power PC 440, un CPU destiné à l'embarqué. Vu qu'il était destiné à l'embarqué, il n'avait pas de FPU, il n'avait pas besoin de manipuler de nombres flottants. Il n'a donc que des ALU entières, un multiplieur et une unité mémoire. Il dispose de deux ports d'émission. Le premier est connecté à une ALU entière et un circuit multiplieur, le second est relié à l'unité mémoire et une seconde ALU entière. Pour résumer : une ALU entière par port, l'unité mémoire sur un port, le multiplieur sur l'autre. Une organisation simple, mais très efficace ! [[File:PowerPC 440.png|centre|vignette|upright=2|Micro-architecture du PowerPC 440.]] Le processeur précédent n'avait pas de FPU, ce qui fait qu'il n'avait pas grand chose à connecter sur ses ports d'émission. le partage des ports d'émission était assez simple. Aussi il est intéressant de voir des exemples avec une FPU. Les exemples classiques sont les processeurs Intel i960 et Pentium. L'implémentation était différente entre l'i960 et le Pentium : Le Pentium avait une seconde ALU entière, alors que le i960 rusait. Le Pentium dupliquait l'ALU entière, pour créer un second pipeline entier. Sur le Pentium originel, les contraintes d'appariement étaient assez strictes. Le Pentium avait une FPU, mais ne pouvait pas l'utiliser en double émission. Il lui était impossible d'exécuter une opération flottante en même temps qu'une opération entière (branchements inclus). Le Cyrix 6x86 était une sorte de Pentium produit par l'entreprise Cyrix, avec une micro-architecture différente. Il avait des contraintes d’appariement réduites. Par exemple, il pouvait émettre une opération flottante en parallèle d'une opération entière. Nous détaillerons ces processeurs dans le prochain chapitre, qui porte justement sur les CPU superscalaires x86. [[File:CPU simple et double émission avec un second pipeline entier.png|centre|vignette|upright=2.5|CPU simple et double émission avec un second pipeline entier.]] L'Intel i960 n'avait pas de FPU, juste une ALU entière et une unité mémoire. Il utilisait l'unité mémoire comme une seconde ALU entière limitée. Pour rappel, une unité mémoire contient une AGU, à savoir une ALU spécialisée dans les calculs d'adresse. Elle est capable de faire des additions, soustractions, et quelques décalages limités (des décalages par 2, 4, 8, pas plus). L'Intel i960 ajoutait des connexions entre AGU et banc de registre, afin que celle-ci serve de seconde unité de calcul. L'AGU est déjà reliée aux deux ports de lecture, pour lire les opérandes du calcul d'adresse, il suffit de la connecter sur le port d'écriture. Le processeur peut alors émettre deux instructions/µops entières, sous certaines conditions. [[File:Emission multiple des opérations entières, double émission.png|centre|vignette|upright=1.5|Émission multiple des opérations entières, double émission.]] ===La double émission INT-FLOAT-MEM=== La '''double émission INT-FLOAT-MEM''' utilise la technique du partitionnement superscalaire, vue plus haut, avec cependant une petite subtilité. Elle utilise précisément une ALU entière, une FPU et une unité mémoire. En théorie, le partitionnement devrait donner un CPU triple émission, pas double émission. Et c'est là que la différence entre ports d'émission et de décodage devient intéressante. L'idée est d'avoir une unité d'émission avec deux ports de décodage, et 2 ports d'émission (un par unité). Les contraintes d’appariement varient alors suivant le processeur, mais la règle générale est que si deux micro-opérations tombent dans deux unités différentes, on peut les émettre en même temps. En clair, on peut émettre une opération entière avec une opération flottante, une opération entière avec un accès mémoire, une opération flottante avec un accès mémoire. Et cela rend le partitionnement bien plus performant que prévu. L'avantage sur la double émission flottante est que les combinaisons INT + MEM et FLOAT + MEM étaient autorisées. Le processeur pouvait donc émettre une opération en parallèle d'un accès mémoire, ce qui est un doublet d'instruction assez fréquent. Le gain en performance par rapport à la doublé émission entière-flottante était substantiel. Un détail est que cette forme de double émission ne peut pas s'interpréter comme un processeur à deux pipelines. Le processeur n'a pas deux ports d'émission, mais trois, et les contraintes d'appariement collent assez mal avec deux pipelines séparés. Il est possible d'interpréter le tout comme un CPU à trois pipelines, mais qui n'autorise que les paires d'instructions, mais ce serait assez tiré par les cheveux. Aussi, la notion de "plusieurs pipelines indépendants" ne marche pas pour décrire ce genre de processeurs superscalaires. Leur nombre de voies reste de deux, mais le nombre de pipelines est indéterminé. Les exemples classiques sont l'HyperSPARC, l'alpha 21064 et le PowerPC 603. Le PowerPC 603 implémentait une double émission "parfaite". C'est à dire que toutes les combinaisons possibles à deux instructions étaient possibles, que ce soit INT + FLOAT, INT + MEM, FLOAT + MEM. En plus de cette double émission "parfaite", le CPU utilisait la technique dite de ''branch folding'', qui permettait d'exécuter un branchement sans utiliser d'unité de calcul, ce qui fait qu'il était qualifié de ''CPU 2-way + Branch''. A l'opposé, le processeur alpha 21064 avait pas mal de contraintes d’appariement, à savoir que certaines combinaisons de deux instructions étaient interdites, bien que compatibles avec les unités disponibles. Une des raisons est que le banc de registre n'avait pas assez de ports de lectures, ce qui fait que certaines combinaisons avec une écriture mémoire n'étaient pas permises. Les concepteurs avaient volontairement inséré ces contraintes afin de simplifier le design du processeur. ===La double émission avec partitionnement et duplication d'ALU entière=== Nous venons de voir qu'il est possible d'utiliser efficacement un surplus de ports d'émission, afin de réduire les contraintes d'appariement. Des ports d'émission qui étaient partagés sont simplement dupliqués et les ALU sont réparties sur des ports d'émission différents. Il est possible de pousser cette logique encore plus loin, en utilisant un grand nombre d'unité de calcul. L'exemple historique le plus impressionnant est celui du Motorola 88110, un CPU double émission qui possède pas moins de 10 unités ! Il contient deux ALU entières, un circuit multiplieur, un circuit diviseur, une unité de manipulation de bits, une unité mémoire, un additionneur flottant, et deux unités graphiques pour des calculs impliquant des pixels. Le multiplieur est utilisé à la fois pour les multiplications entières, flottantes et le produit de deux pixels. Idem pour le diviseur, qui sert pour les divisions entières et flottantes. Et le partitionnement est poussé au point où chaque unité a son propre port d'émission. La conséquence est que les contraintes d'appariement du Motorola 88110 sont simples : tant que deux µops atterrissent dans des unités séparées, la paire est permise. Il est par exemple possible d'émettre simultanément deux opérations entières simples, vu qu'il y a deux ALU. Ou encore, d'émettre une addition et une multiplication, une multiplication et un décalage, un décalage et une division, une addition entière et une addition flottante, etc. Par contre, impossible d'émettre simultanément deux multiplications, deux divisions, deux opérations flottantes, deux manipulations de bit, deux accès mémoire, etc. De plus, le partage du multiplieur fait qu'on ne peut pas émettre une multiplication entière avec une multiplication flottante. Idem avec les divisions, vu que le diviseur est aussi partagé. Ce sont les seules contraintes, et c'est très peu comparé aux autres processeurs double émission. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le parallélisme mémoire | prevText=Le parallélisme mémoire | next=Les processeurs superscalaires out-of-order | nextText=Les processeurs superscalaires out-of-order }} </noinclude> jjamy0u36pbef1rp86nhcnfxd7pfaby 774168 774159 2026-10-04T18:31:54Z Mewtow 31375 /* La double émission entière */ 774168 wikitext text/x-wiki Les processeurs vus auparavant ne peuvent émettre au maximum qu'une instruction par cycle d'horloge. Ils peuvent avoir plusieurs instructions qui s'exécutent en même temps, dans des unités de calcul séparées. C'est le cas dès qu'une instruction multi-cycles s'exécute. Par contre, ils ne peuvent démarrer qu'une seule instruction par cycle. 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'''. Un processeur à émission multiple charge plusieurs instructions en même temps, les décode en parallèle, puis les émet en même temps sur des unités de calculs séparées. Pour cela, 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. En général, pour un processeur à émission multiple capable d'émettre N instructions en parallèle, on parle de '''processeur à N voies''', ou encore un ''processeur à N pipelines''. [[File:Superscalarpipeline.svg|centre|vignette|upright=1.5|Pipeline RISC classique à cinq étages sur un processeur superscalaire. On voit bien que plusieurs instructions sont chargées en même temps.]] 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 qu'ils ont un jeu d'instruction spécialisé qui expose le parallélisme d'instruction directement au niveau du jeu d'instructions, là où les processeurs superscalaires restent des processeurs au jeu d'instruction normal. La différence principale entre processeur VLIW et superscalaire 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. Nous allons voir uniquement les processeurs superscalaires dans ce chapitre. Et plus précisément ceux sans exécution dans le désordre. Un processeur superscalaire peut utiliser l'exécution dans le désordre, mais ce n'est pas une obligation. Historiquement, les premiers processeurs superscalaires étaient des processeurs ''in-order'' (ce qui veut dire qu'ils n'utilisaient pas d'exécution dans le désordre). Le meilleur exemple est celui du Pentium 1 et 2 d'Intel. Ils étaient tous deux des processeurs superscalaires ''in-order''. Dans ce chapitre, nous allons voir uniquement les processeurs superscalaires ''in-order'' pour simplifier les explications. Il faut cependant garder à l'esprit que les explications qui vont suivre marchent aussi pour les processeurs superscalaires ''out of order'', sauf pour quelques exceptions. Aussi, ce chapitre servira d'introduction au chapitre suivant. ==Le nombre de voies d'un processeur superscalaire== En général, les processeurs superscalaires ''in-order'' 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 triple émission permettent eux d’émettre trois instructions à la fois, mais ils sont plus rares. A l'opposé, les CPU superscalaires les plus puissants peuvent émettre jusqu'à 8-10 instructions simultanément. En 2026, les CPU superscalaires à exécution dans le désordre peuvent monter jusqu’à 10 µops émises en même temps, ce qui est énorme ! Et cela nous amène à faire une distinction entre les processeurs superscalaires dits '''étroits''' et ceux dits '''larges'''. Dans ce wikilivre, on part du principe que les processeurs superscalaires étroits émettent jusqu'à 4-5 µops à la fois, alors que les larges peuvent en émettre plus, l'intervalle entre les deux est une zone grise. Le nombre exact n'est pas fixé dans le marbre, la distinction est volontairement vague. Nous parlerons de la '''largeur d'un CPU superscalaire''' pour parler du nombre de voies, du nombre de µops émises en même temps. Plus un CPU superscalaire est large, plus il est performant ''toutes choses égales par ailleurs''. Mais dans les faits, vous pouvez oublier la partie en italique. Élargir un CPU superscalaire demande de rajouter beaucoup de circuits. Et ce cout en circuit aura un impact sur la performance. À budget en transistors limité, il y a un compromis entre élargir un CPU et rajouter du cache ou la taille des ''schedulers''/ROB et autres. ===Les contraintes d’appariement=== Prenons un processeur double émission, capable d'émettre deux instructions simultanément. Émettre deux instructions en même temps n'est pas toujours possible. Par exemple, si deux instructions sont dépendantes, on ne peut pas les émettre en même temps. Mais oublions un instant ces histoires de dépendances de données, et partons du principe que le CPU ait affaire à deux instructions indépendantes, une paire idéale. Un CPU double émission "parfait" pourrait exécuter n'importe quelle paire d'instruction. Par exemple, il pourrait émettre deux multiplications consécutives, deux lectures consécutives, une lecture et une écriture en même temps, etc. En pratique, les processeurs qui font ça sont rare. Implémenter un CPU double émission "parfait" a un cout en circuit conséquent. Par exemple, pour émettre deux multiplications en même temps, il faudrait deux multiplieurs séparés. Idem pour exécuter deux accès mémoire simultanés : cela demande d'avoir deux unités mémoire et un cache multiport. La totalité des CPU double émission se débrouille avec un seul circuit multiplieur et une seule unité mémoire. Et cela entraine l'apparition de dépendances structurelles, qui font que certaines paires d'instructions sont impossibles. Par exemple, impossible d'émettre deux multiplications en même temps avec un seul circuit multiplieur. Mais émettre une multiplication en parallèle d'une addition est parfaitement possible. Il y a donc des paires d'instructions autorisées et des paires interdites. Pour le dire autrement, il y a des '''contraintes d'appariement''' sur les instructions à émettre en même temps. Ces contraintes d’appariement ont un impact sur les performances, mais celui-ci peut être grandement mitigé. Les programmes informatiques utilisent en pratique beaucoup plus d'opérations entières de base que de multiplication, branchements et accès mémoire. Il y a environ 5 opérations entières de base pour une multiplication entière. En pratique, il est rare de devoir exécuter deux multiplications à la fois. Idem pour les décalages et rotations, qui sont assez éloignés les uns des autres. Deux branchements consécutifs sont quant à eux plus fréquents. Deux accès mémoire consécutifs est assez rare, mais ils restent assez proches les uns des autres malgré tout. La conséquence est que certaines paires d'instructions sont privilégiées par rapport au reste. Les paires regroupant deux calculs basiques (additions, soustractions, ...) sont systématiquement supportées, car dupliquer des ALU entières est facile. Les paires regroupant un calcul et une autre instruction sont elles aussi systématiquement supportées. Par contre, les paires avec deux branchements, deux accès mémoire, deux décalages/rotations, ou deux multiplications/divisions sont fréquemment interdites. Seuls les CPU superscalaires larges peuvent les permettre, et encore : sous conditions. Le résultat est que le processeur peut se débrouiller avec une seule unité mémoire et une unité de branchement. Décrire les contraintes d’appariement demande de dire quelles combinaisons d’instructions sont autorisées ou interdites. Et pour cela, nous allons introduire les abréviations suivantes : * INT est un raccourci pour parler d'une opération entière simple, qu'une ALU entière peut exécuter. * MUL correspond à une opération entière complexe, typiquement une multiplication, éventuellement à une division. * FLOAT désigne une opération flottante, peut importe laquelle. * MEM désigne un accès mémoire, à savoir une instruction ou µop mémoire. * BRANCH désigne une instruction de branchement. Cette terminologie permettra de définir des paires d'instructions, qu'elles soient possibles ou interdites. Par exemple, l'expression INT + FLOAT désigne une paire d'instruction regroupant une instruction entière et une instruction flottante, peu importe l'ordre des deux instructions. Il en sera de même pour les triplets, quadruplets, ou plus. Par exemple, l'expression INT + INT + FLOAT + MUL désigne un quadruplet d'instructions regroupant : deux instructions entières, une instruction flottante, et une multiplication entière. J'utiliserais aussi des parenthèses pour regrouper certaines combinaisons. Par exemple, l'expression INT + (MUL /FLOAT) veut parler d'une paire d'instruction où la première est une opération entière basique, la seconde est : soit une multiplication, soit une opération flottante. ===L'historique simplifié de la superscalairité=== L'histoire des CPU superscalaires a commencé dès 1960, mais est resté un projet de recherche pendant plusieurs décennies. De nombreux designs ont été conçus, mais abandonnés avant la production, comme l'ACS-1 d'IBM. Les années 1980 ont vu l'arrivée de prototypes de processeurs VLIW, mais qui n'étaient pas superscalaires. Par exemple, l'Apollo DN10000 et l'Intel i860 pouvaient émettre deux instructions à la fois, sous conditions, mais demandaient que le compilateur fasse une bonne partie du travail. Les premiers CPU superscalaires commercialisés sont sortis en 1989. Deux CPUs superscalaires sont sortis cette année-là : le RS/6000 d'IBM et l'i960CA d'Intel. Puis, la compétition s'est aussi lancée dans la bataille. Les CPU commercialisés avant l'année 1993 étaient à double émission, à l'exception du SuperSPARC qui utilisait la triple émission. Les modèles après 1993 hésitaient entre double et quadruple émission. Dans le tableau ci-dessous, les processeurs quadruple émission sont en bleu, les triple émission sont en rouge, les autres ne sont pas en couleur. {|class="wikitable" |- ! !! IBM !! HP PA-RISC !! SPARC !! Intel!! Alpha !! Autres |- ! 1989 | RS/6000 || || || i960CA || || |- ! 1990 | || || || || || |- ! 1991 | || || || || || Motorola 88110 |- ! 1992 | RSC || PA 7100 || class="f_rouge | SuperSPARC || || 21064 || |- ! 1993 | PPC 601 || || HyperSPARC || Pentium || || |- ! 1994 | class="f_bleu | POWER 2 || PA 7200 || || || class="f_bleu | 21164 || |- ! 1995 | 603/604 || class="f_bleu | PA 8000 || class="f_bleu | UltraSPARC 1 || Microarchitecture P6 (+ concurrence AMD) || || class="f_bleu | MIPS R1000 |- ! 1996 | class="f_bleu | 620 || || || || class="f_bleu | 21264 || |- ! ... || ... || ... || ... || ... || ... || ... |} ==Le ''front-end'' des processeurs superscalaires== Un processeur superscalaire doit charger, décoder, et exécuter plusieurs instructions par cycle. Par exemple, un processeur triple émission doit charger 3 instructions lors d'un premier cycle, puis les décoder au cycle suivant, et les exécuter au cycle suivant. Il travaille donc pas paquets de 3 instructions, dans cet exemple. Si c'est un processeur superscalaire ''in-order'', cela donne l'impression que le pipeline dispose de plusieurs '''pipelines séparés'''. Intuitivement, charger, décoder et exécuter plusieurs instructions à la fois demande de dupliquer des circuits. Par exemple, il faut dupliquer les décodeurs pour décoder plusieurs instructions. Les unités de calcul sont aussi dupliquées, sauf sur certains processeurs qui arrivent à ruser, mais laissons cela de côté pour plus tard. Mais d'autres circuits sont simplement modifiés, comme l'unité de chargement ou l'unité d'émission. Voyons ce qu'il en est dans le détail. {|class="wikitable" |- ! rowspan="2" | Processeur sans émission multiple | rowspan="2" | Chargement | rowspan="2" | Décodage | rowspan="2" | Émission | Exécution / ALU | rowspan="2" | ''Commit''/ROB |- | Exécution / ALU |- ! rowspan="4" | Processeur superscalaire | rowspan="4" | Chargement | rowspan="2" | Décodage | rowspan="4" | Émission | Exécution / ALU | rowspan="4" | ''Commit''/ROB |- | Exécution / ALU |- | rowspan="2" | Décodage | Exécution / ALU |- | Exécution / ALU |} Nous allons d'abord voir ce qu'il en est pour le ''front-end'', à savoir la partie du CPU qui s'occupe de charger et décoder les instructions. Elle regroupe l'unité de chargement, les décodeurs, l'unité de prédiction de branchement, la file de µops, la file d'instruction, et divers caches. En soi, les changements portent surtout sur l'unité de chargement et les décodeurs. ===Le chargement de plusieurs instructions consécutives=== Un processeur superscalaire charge plusieurs instructions en même temps, ''en tenant compte de la présence de branchements''. La partie en italique est importante et elle a une conséquence très forte : la manière de faire est fortement différente entre un CPU superscalaire étroit et large. Un CPU superscalaire étroit fait au plus simple : il charge un bloc de 2, 3, 4 instructions consécutives en mémoire RAM. Il faut alors doubler/tripler/quadrupler la taille du bus connecté au cache d'instruction. Par exemple, pour charger quatre instructions de 32 bits chacune, il suffit de lire un bloc de 128 bits dans le cache d'instruction. Un bloc de 8, 16, 32 octets est donc lu depuis le cache et est ensuite découpé en instructions, envoyées chacun à un décodeur. Charger plusieurs instructions consécutives ne marche bien qu'en absence de branchements. La présence de branchements casse cette dynamique, et il faut gérer leur présence. * Sans prédiction de branchement, toutes les instructions d'un bloc sont décodées et exécutées en même temps, même s'il y a un branchement dans le tas. * Avec prédiction de branchement, les instructions situées après le branchement ne sont pas décodées ni exécutées. L'unité de chargement coupe le bloc chargé au niveau du premier branchement pris, remplit les vides avec des NOP, avant d'envoyer le tout à l'unité de décodage. Les instructions à l'adresse de destination seront chargées au cycle suivant, en chargeant un nouveau bloc. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un autre défaut est que sur le CPU charge un bloc de 128 bits à la fois, les instructions doivent être alignées sur 128 bits. Et on peut généraliser à n'importe quelle taille de bloc. Si les instructions ne sont pas alignées, il faut gérer la situation. Il arrive qu'une instruction soit à cheval sur deux blocs, avec un morceau dans le premier bloc, un autre dans le second. Dans ce cas, l'instruction est chargée en deux fois, et des circuits doivent gérer la situation pour recoller les morceaux. Sur les CPU avec des instructions de taille fixe, le problème ne survient que rarement. Mais il est fréquent avec des instructions de taille variable. Un autre défaut est lié aux branchements. Imaginez qu'un branchement s'exécute et branche vers une instruction de destination. Rien n'indique que l'instruction de destination sera au tout début d'un bloc. Si ça se trouve, elle sera en troisième position d'un bloc de 4 instructions. Une solution simple charge le bloc en respectant l'alignement, et les deux premières instructions du bloc ne sont pas à exécuter. Il y a donc une petite perte de performance. Certains CPU, comme l'IBM RS/6000, ont la machinerie charger un bloc de N instructions même s'il n'est pas aligné. La machinerie en question se trouve dans le cache d'instruction et est tout sauf simple à expliquer, ce qui fait que je me permets de la passer sous silence. Un détail, cependant : elle ne permet pas de charger des blocs qui sont à cheval sur deux lignes de cache. ===Le décodage parallèle des instructions=== Pour le décodage des instructions, plusieurs instructions sont décodées en même temps. On parle alors de '''décodage parallèle'''. Pour ce faire, un processeur superscalaire contient plusieurs décodeurs, chacun pouvant décoder une instruction en parallèle des autres. Prenons par exemple un processeur superscalaire à 4 voies : le décodage se fera dans quatre décodeurs séparés, qui fonctionneront en parallèle. Cependant, cette implémentation ne marche bien que pour les processeurs RISC, les processeurs CISC devant faire avec un microcode gourmand en circuits. Les processeurs CISC utilisent des décodeurs hybrides, avec un microcode qui complémente un décodeur câblé. Dupliquer le microcode aurait un cout en transistors trop important, ce qui fait que seuls les décodeurs câblés sont dupliqués. Les CPU CISC superscalaires disposent donc de plusieurs décodeurs simples, capables de décoder les instructions les plus courantes, avec un seul microcode. La conséquence est qu'il n'est pas possible de décoder deux instructions microcodées en même temps. Par contre, il reste possible de décoder plusieurs instructions non-microcodées ou une instruction microcodée couplée à une instruction non-microcodée. Vu qu'il est rare que deux instructions microcodées se suivent dans un programme, le cout en performance est extrêmement mineur. Il faut noter qu'une instruction correspond à une ou plusieurs µops. Il est donc possible de charger 5 instructions et de se retrouver avec 8 µops en sortie des décodeurs. Et c'est la source d'une distinction très importante. Plus haut, j'ai parlé de processeurs superscalaires à N voies. La terminologie peut avoir deux interprétations. La première est que le processeur charge/décode N instructions à la fois. Une seconde est qu'il est capable d'exécuter/émettre N µops en même temps. Les deux ne sont pas équivalentes, vu qu'un processeur superscalaire peut, par exemple, charger/décoder N instructions et émettre/exécuter N+2 µops. La distinction est particulièrement importante sur les processeurs superscalaires larges. Certains d'entre eux sont capables de charger 5 instructions et d'en émettre le double ! Un exemple classique était les processeurs Athlon d'AMD, qui pouvaient décoder deux instructions par cycle, mais émettre 4 µops par cycle. Ce n'était pas un problème, car beaucoup d'instructions, même basiques, étaient décodées en 2 µops. ===L'émission parallèle des instructions=== La superscalarité modifie en profondeur l'unité d'émission, notamment au niveau de son interface. Avant, elle recevait une micro-opération sur son entrée, et fournissait la micro-opération émise sur une sortie. Et cela vaut aussi bien pour une unité d'émission simple, un ''scoreboard'', un ''scheduler'' 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. Dans ce qui suit, nous ne parlerons pas d'entrée et de sortie, mais de '''ports de décodage''' et de '''ports d'émission'''. Les ports de décodage reçoivent des micro-opérations provenant de l'unité de décodage, les ports d'émissions sont pour les micro-opérations émises. Les ports d'émission injectent les micro-opérations émises dans le pipeline, elles sont propagées au banc de registre, aux ALUs, etc. Un processeur superscalaire a forcément plusieurs ports d'émission (et de décodage). [[File:Ports de décodage et d'émission.png|centre|vignette|upright=2|Ports de décodage et d'émission]] Un détail important est que l'unité d'émission peut avoir plus de ports d'émission que de ports de décodage. Et n'allez pas croire qu'un surplus de ports d'émission est du gâchis. Sur les processeurs avec exécution dans le désordre, ce surplus peut être utilisé pour émettre une µops sur chaque port d'émission par cycle d'horloge, ce qui donne un gain de performance appréciable. Ceci dit, les processeurs sans exécution dans le désordre utilisent aussi des ports d'émission en surplus. La raison sera expliquée dans quelques sections, mais nous pouvons dire que cela simplifie la conception du processeur. Si l'unité d'émission a un surplus de ports d'émission, on doit alors distinguer sa ''largeur de décodage'' et sa ''largeur d'émission''. La '''largeur de décodage''' est le nombre de µops fournies par l'unité de décodage par cycle d'horloge, la '''largeur d'émission''' est le nombre maximal de µops émises par cycle d'horloge. La largeur d'émission est égale ou supérieure à la largeur de décodage, tout dépend du processeur. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] Si l'interface de l'unité d'émission change, l'intérieur est tout autant modifié. Sur un CPU non-superscalaire, l'unité d'émission reçoit une µop "candidate à l'émission", et détecte les dépendances entre avec les µops en vol, en cours d'exécution dans le pipeline. Et c'est toujours le cas sur les processeurs superscalaires : l'unité d'émission détecte les dépendances entre les µops candidates et les µops en vol. Mais avec la siperscalarité, elle doit en plus tester les dépendances entre les µops candidates. Si l'unité d'émission est un ''scoreboard'', il doit détecter les dépendances entre instructions émises simultanément et les sérialiser si besoin. Avec un ''scheduler'', il n'y a pour ainsi dire rien à faire, vu que les dépendances sont éliminées par le renommage de registre et que les signaux de réveil gèrent les dépendances RAW. C'est la raison pour laquelle les processeurs superscalaires modernes n'utilisent pas de ''scoreboard''. ==Le ''back-end'' des processeurs superscalaires== Posons-nous cette question : de combien d'unités un processeur superscalaire a besoin ? Les unités en question ne sont pas nécessairement des unités de calcul, l'unité mémoire et l'unité de branchement comptent aussi. La réponse dépend du nombre de voies. Si le ''front-end'' charge et décode un paquet de N instructions, exécuter ces N instructions simultanément demandera là aussi au moins N unités de calcul. Notez le terme : ''au moins'', car c'est là tout l'enjeu de cette section. La réponse dépend aussi des contraintes d'appariement. Pour un CPU superscalaire "parfait", sans contraintes d’appariement, toutes les unités doivent être dupliquées, y compris l'unité mémoire. Un processeur à double émission parfaite devrait dupliquer l'ALU entière, le ''barrel shifter'', le circuit multiplieur, la FPU, l'unité mémoire, et l'unité de branchement, ce qui ferait passer de 6 unités à 12. Le cout en circuit est énorme, même pour de la double émission ! Et pour un CPU à N pipelines, le cout en circuit encore plus prohibitif ! En pratique, les processeurs superscalaires qui font ça sont très rares. La majeure partie des processeurs superscalaires utilisent d'autres techniques : le partitionnement, la duplication partielle des ALUs, la duplication des autres unités. ===Le partitionnement des unités fonctionnelles=== Les CPU superscalaires les plus simples ne dupliquent pas leurs unités de calcul, ils se contentent d'exploiter les unités existantes. Et pour expliquer comment, nous allons partir de l'exemple d'un processeur non-superscalaire contient une ALU entière, une FPU, une unité mémoire et une unité de branchement. Les quatre unités peuvent fonctionner en parallèle, à savoir qu'une unité peut exécuter une instruction à elle toute seule, sans interagir avec les autres. Cela suffit pour créer un processeur quadruple émission, qui peut émettre en même temps : une opération entière, une opération flottante, un branchement, un accès mémoire. Le principe marche aussi sur un processeur avec moins de 4 unités, par exemple sans FPU et/ou sans unité de branchement, ou avec au contraire des unités en plus. L'idée générale est d’émettre une µops séparée dans chaque unité fonctionnelle. Utiliser ainsi les unités existantes, pour rendre un CPU superscalaire, s'appelle le '''partitionnement superscalaire'''. Il consiste à attribuer un port d'émission à chaque unité existante. Le terme n'est pas beaucoup utilisé en dehors de quelques vieux articles académiques. Le partitionnement est fortement dépendant du jeu d'instruction, car il demande de bien séparer instructions entières, flottantes, branchements et instructions mémoire. Et autant c'est bien le cas sur les CPU RISC, autant les CPU CISC ne sont pas dans ce cas. Les CPU CISC ont des instructions ''load-op'', qui effectuent une instruction arithmétique et une µop mémoire. Et ces instructions sont très compliquées à implémenter avec le partitionnement. Et au-delà de ça, les CPU CISC ont beaucoup d'instructions qui sont décodées en plusieurs µops, à exécuter dans un ordre bien précis. On peut pas émettre les µops en question simultanément. Le partitionnement a un cout en circuit très limité, pour un gain en performance lui aussi limité. Pour reprendre le CPU de l'exemple précédent, il est rare que l'on ait à exécuter ces quatre instructions en même temps. En pratique, les combinaisons fréquentes sont les combinaisons calcul + accès mémoire, calcul + branchement, calcul entier + calcul flottant. Utiliser une quadruple émission de ce type n'est donc pas une bonne idée, alors que la double émission parait plus adaptée. Avant de poursuivre, précisons que la FPU regroupe un additionneur flottant et un multiplieur flottant. Et ce fait peut être exploité pour approfondir le partitionnement. La '''double émission FADD/FMUL''' permet d'émettre une addition flottante en même temps qu'une multiplication flottante. Elle a été utilisée sur les processeurs x86, Alpha et SPARC. Les autres circuits de calcul flottant sont souvent regroupés avec l'additionneur. La division est parfois e même port que le multiplieur flottant, parfois sur celui de l'additionneur (quand elle est microcodée et exécutée en enchainant les soustractions). ===La duplication des ALU entières=== Le partitionnement est une technique élégante, simple, facile à implémenter et au cout en transistors modeste. Mais il faut avouer que cela ne donne pas des CPU superscalaires très efficaces. Le partitionnement permet d'émettre des paires, triplets, quadruplets de micro-opérations qui sont rares en pratique. Et les paires, triplets, quadruplets d'instructions courants ne sont pas supportés. Les contraintes d’appariement sont trop strictes. Les combinaisons les plus fréquentes en pratique sont des paires/triplets d'opérations entières, souvent des paires/triplets d'additions ou d'opérations entières simples. Il est donc primordial d'émettre plusieurs opérations entières simultanément. Le fait d'émettre et d'exécuter plusieurs opérations entières en même temps s'appelle l''''émission entière multiple'''. Pour exécuter la seconde/troisième opération entière, il faut dupliquer les ALU entières (ou réutiliser l'AGU comme ALU entière comme le fait l'Intel i960). Le processeur doit avoir deux ALU entières pour de la double émission entière, trois pour de triple émission entière, etc. De plus, il faut ajouter des ports au banc de registre, pour lire deux fois plus d'opérandes et décrire deux résultats. Et enfin, il faut rajouter un port d'émission pour pouvoir émettre une micro-opération entière en plus. [[File:Processeur non-superscalaire basique 03.png|centre|vignette|upright=2|Processeur non-superscalaire basique]] La tendance est de multiplier les ALU entières, mais pas le circuit multiplieur. Le cout en transistors est alors grandement réduit, au prix de l'apparition de dépendances structurelles. Le CPU peut émettre deux additions en même temps, ou une addition et une multiplication, mais pas deux multiplications. Il en est de même avec le ''barrel shifter'', qui n'est pas dupliqué. Il est donc possible d'émettre un décalage avec une opération de base, mais pas deux décalages en parallèle. Mais ces contraintes d’appariement ne sont pas un problème, car il est rare de devoir émettre deux multiplications/décalages en même temps. La plupart des programmes sont majoritairement remplis d'additions, avec des multiplications assez rares et des décalages qui le sont encore plus. La moyenne est proche d'une multiplication pour 4/5 additions. Vu que des multiplications consécutives sont très rares, dupliquer le multiplieur est presque inutile. Disposer de plusieurs circuits multiplieurs ou de plusieurs ''barrel shifters'' serait donc un cout en circuits qui ne servirait que rarement et n'en vaut pas la chandelle. Il faut noter que sur les CPU 32 bits, les multiplications peuvent être exécutées dans la FPU ou dans un multiplieur séparé, tout dépend de si le CPU exécute les multiplications dans la FPU ou pas. {|class="wikitable" |- ! Voie d'émission n°1 !! Voie d'émission n°2 !! Voie d'émission n°3 |- | ALU entière || ALU entière || FPU |} Reste à connecter les unités de calcul à l'unité d'émission (indirectement, car il y a des étages de pipeline entre les deux). Une implémentation basique utilisée un port d'émission par unité. Prenons l'exemple d'un CPU superscalaire avec 2 ALU entières, un multiplieur entier, un ''barrel shifter'' une FPU, et une unité mémoire. Vu qu'il y a 6 unités, cela fait donc 6 ports d'émission. C'est beaucoup, l'unité d'émission est très complexe. Suffisamment complexe pour se demander s'ils n'existerait pas des optimisations qui permettraient d'en réduire le nombre sans trop réduire les performances, ni le nombre d'ALU. Et cela nous amène à la section suivante. ===Le partage des ports d'émission=== La présence d'un circuit multiplieur et d'un ''barrel shifter'' pose des problèmes pour le partitionnement. Il est possible d'avoir un port d'émission dédié au multiplieur, et un autre au ''barrel shifter'', qui sont séparés de ceux pour l'ALU entière. Il est alors possible d'émettre une opération entière, une multiplication et un décalage, simultanément. Nous parlerons de '''partitionnement entier''' pour désigner cette possibilité. [[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]] Le partitionnement entier est cependant très rare, car le gain en performance serait trop faible au regard des couts en circuit. Quelques processeurs historiques l'utilisaient, mais ce n'est plus le cas de nos jours. La raison est que les multiplications et les décalages sont assez rares dans le code, ce qui fait que le port d'émission associés sont sous-utilisés. Et un port d'émission a un cout en circuit assez important. Et c'est sans compter qu'ajouter des ports sur le banc de registres juste pour un multiplieur sous-utilisé n'en vaut pas la peine. L'idéal serait de trouver un moyen de réduire le nombre de ports d'émissions, sans réduire le nombre d'unités de calcul. [[File:Emission multiple des opérations entières, implémentation naive.png|vignette|Émission multiple des opérations entières, implémentation naive]] La solution consiste à '''partager les ports d'émission''', à savoir connecter un port d'émission à plusieurs unités de calcul. L'exemple classique est justement lié aux circuits multiplieur et ''barrel shifters''. L'idée est que le multiplieur et le ''barrel shifter'' sont mis sur le même port d'émission qu'une ALU entière. Prenons par exemple le processeur à double émission entière illustré ci-contre. Un port d'émission est relié à une ALU entière seule, et l'autre port d'émission alimente la seconde ALU entière et le multiplieur. La conséquence est que le processeur peut émettre deux opérations entières, dont une multiplication. Si on avait utilisé un port d'émission dédié au multiplieur, il aurait été possible d'émettre deux opérations entières et une multiplication. On perd donc une voie, mais on gagne en flexibilité et on économise des circuits. Notons que ce partage signifie aussi que le multiplieur partage les ports sur le banc de registre, ce qui fait une double économie. [[File:Emission multiple des opérations entières, implémentation courante.png|centre|vignette|upright=1.5|Émission multiple des opérations entières, implémentation courante]] En général, le ''barrel shifter'' et le multiplieur sont placés sur deux voies d'émission séparés, ce qui permet d'émettre un décalage et une multiplication en même temps. Les mettre sur le même port ne le permettrait pas. {|class="wikitable" |- ! Voie d'émission n°1 !! Voie d'émission n°2 !! Voie d'émission n°3 |- | * ALU entière * Multiplieur | * ALU entière * Barrel shifter | FPU |- | * Additions, soustractions, opérations bit à bit, etc. * Multiplication | * Additions, soustractions, opérations bit à bit, etc. * Décalages et rotations | Opération flottante |} Terminons concernant les multiplieurs avec une dernière subtilité. Pour rappel, les processeurs modernes intègrent en réalité deux multiplieurs différents. Le résultat d'une multiplication est très large et prend deux registres. Par exemple, une multiplication de deux opérandes 64 bits donne un résultat sur 128 bits, ce qui ne tient pas dans un registre, mais en prend deux. L'instruction de multiplication classique ne calcule que les 64 bits de poids faible. mais elle est complétée par d'autres instructions de multiplication qui peuvent faire le calcul complet. Pour résoudre le problème, les processeurs modernes intègrent deux instructions de multiplications : une qui calcule les bits de poids faible du résultat, une autre qui calcule les bits de poids fort. Et ces deux instructions sont associées à deux multiplieurs séparés, aux circuits différents. Le multiplieur qui calcule les bits de poids faible du résultat est appelé le ''low multiplier'', l'autre est appelé le ''high multiplier''. Le second multiplieur est souvent placé sur un second port d'émission, différent du premier multiplieur, partagé avec une ALU. L’avantage de mettre les deux multiplieurs sur deux ports d'émission différents est qu'une multiplication complète peut être réalisée en utilisant les deux multiplieurs en parallèle. Les deux sous-multiplications sont émises en même temps, exécutées en même temps. pas besoin d'exécuter l'une puis la seconde, d'où un gain performance appréciable. Nous venons de voir comment partager un port d'émission entre une ALU et un multiplieur. Mais au-delà de cet exemple, le partage des ports d'émission peut appliquer à n'importe quelle unité. Prenons l'exemple d'un CPU superscalaire avec 4 ALU entières, une FPU, et une unité mémoire. Vu qu'il y a 6 unités, cela fait donc 6 ports d'émission. Cependant, il est rare qu'un bloc de 6 instructions contienne exactement 4 opérations entières, une opération flottante, et un accès mémoire. Beaucoup de ports d'émission seraient sous-utilisés. La solution est de partager des ports d'émission, pour passer à la quadruple émission. Le CPU peut alors répartir 4 instructions sur les ports d'émission adéquats, à chaque cycle. Le CPU a donc 4 ports de décodage et 6 ports d'émission. Une implémentation classique permet n'importe quelle combinaison de 4 instructions compatibles avec les 6 unités. Par exemple, il devient possible d'émettre simultanément 4 additions, la combinaison 2 * INT + FLOAT + MEM, 3 * INT + MEM, et bien d'autres. L'exemple relie une ALU entière, la FPU et l'unité mémoire sur le même port. Le CPU a alors quatre ports d'émission, qui ne sont pas "identiques". Trois d'entre eux sont reliés à une ALU entière chacun, rien d'autre. Le quatrième est lui relié à une ALU entière, la FPU et l'unité mémoire. On économise alors deux ports d'émission, mais cela entraine l'apparition de contraintes d’appariement. Le CPU peut émettre 3 opérations entières + n'importe quelle autre opération. Mais les autres combinaisons sont interdites. Le CPU peut émettre 6 opérations entières simultanément, grâce à 6 ALU entières. Les accès mémoire ou opérations flottantes ne viennent pas en plus, mais en remplacement des opérations entières. Regrouper plusieurs ALU sur un même port d'émission est donc à l'origine de dépendances structurelles, de nouvelles contraintes d’appariement. Dans certaines situations de '''''port contention''''', on a assez d'ALU pour exécuter N micro-opérations, mais la répartition des ALUs sur les ports l'empêche. Impossible d'émettre deux micro-opérations sur deux ALU si elles sont sur le même port d'émission. On peut réduire les cas de ''port contention'' en répartissant correctement les ALU sur les ports d'émission, mais sans jamais les supprimer complétement. Un exemple est celui du PowerPC 604. Il implémentait la quadruple émission, alors qu'il avait 5 unités de calcul. Mais deux unités partageaient un même port d'émission. Dans le détail, il avait deux ALU entières, un circuit multiplieur/diviseur, une FPU, une unité mémoire. La FPU et le multiplieur étaient sur le même port d'émission. En conséquence, le CPU pouvait émettre seulement les combinaisons INT + INT + MEM + (MUL/FLOAT). : Il est intéressant de voir l'exemple d'un processeur superscalaire parfait, sans contraintes d'appariement, à N voies. Pour cela, on part d'un processeur non-superscalaire, avec M unités de calcul. Un tel processeur peut s'implémenter avec N ports d'émission, chacun relié à M unités de calcul possibles, ce qui fait que les M unités de calcul sont dupliquées en N exemplaires. Si on prend par exemple un CPU non-superscalaire avec une ALU, une FPU, une unité mémoire et une unité de branchement, un port d'émission sera relié à une ALU, une FPU, une unité mémoire et une unité de branchement. Dupliquer les ALU entière est une première étape, mais il est aussi possible de dupliquer d'autres unités, comme l'unité mémoire ou l'unité de branchement. La rentabilité en performance dépend surtout de si cela autorise des paires d'instructions fréquentes. Le cout en circuit n'est aussi pas le même. Sur les CPU superscalaires ''in-order'', seuls les ALU et FPU sont dupliquées. ===Les CPU superscalaires mélangent les trois techniques=== Les CPU superscalaires utilisent toutes les techniques à la fois : partitionnement, duplication de l'ALU entière, duplication des autres unités fonctionnelles. Chaque processeur utilise ces trois stratégies à sa sauce, en fonction de son budget en transistor. Les quatre techniques ont cependant un cout en circuit différent, et donnent des gains de performances eux aussi forts différents. * Le '''partitionnement superscalaire''' a un cout en circuit modéré, car on n'a pas besoin de dupliquer d'ALU. * La '''duplication des ALU entières''' est la technique la plus importante : elle a un cout en circuit modéré, et donne des gains en performance impressionnants. * Le '''partage des ports d'émission''' réduit le cout en circuit du CPU, pour des pertes de performances mineures si bien fait. L'usage du partitionnement seul, sans duplication, était surtout le fait des processeurs commercialisés entre les années 1989 et 1993. De nos jours, les processeurs utilisent un mix différent d'unités, et leur partitionnement est différent. Ils tendent à s'adapter au mieux aux besoins des programmes exécutés. D'après diverses estimations, un programme informatique exécute environ 40% de son code dans des instructions arithmétiques, un autre 40% dans les accès mémoire, et un le reste pour... le reste. L'idéal est donc de pouvoir émettre autant de µops mémoire que de µops entières, à chaque cycle. Dit autrement, la largeur du processeur doit pouvoir être répartie ''moitié opération - moitié mémoire''. Bien évidemment, le processeur peut gérer d'autres combinaisons. Pour s'adapter, les processeurs dupliquent d'abord les ALU entières, suivi par l'unité mémoire, et enfin par les autres unités. Pour s’adapter au mieux, un processeur à N voies doit disposer d'au moins N/2 ALU entières, l'idéal étant d'aller au-delà. Les CPU superscalaires historiques avaient 2 ALU pour de la quadruple émission, ce qui était très limite. Les CPU superscalaires modernes mettent le paquet niveau ALU entière. La plupart des CPU x86 modernes descendent rarement sous les 2/3, la plupart remplissent presque la totalité de la largeur du CPU. Pour donner un exemple, la micro-architecture Golden Cove d'Intel est un processeur à 6 voies, avec 5 ALU entières. Ensuite, les processeurs modernes dupliquent leurs unités mémoire. Par exemple, la micro-architecture ''Golden Cove'' d'Intel dispose de trois unités mémoire : deux spécialisées dans les écritures, trois dans les lectures. La micro-architecture ''Gracemont'' utilise la quintuple émission et peut émettre 4 µops entières et 4 µops mémoire. Notez que le ratio est équilibré entre opérations entières et mémoire, avec soit égalité, soit un avantage pour les ALU entières. Une autre régularité est que la largeur totale du processeur peut être occupée par des opérations entières/mémoire. Pour le dire autrement, sur un processeur quadruple émission, il pourra émettre un mix de 4 instructions entière/mémoire, avec zéro opération flottante. Idem avec des processeurs triples ou quintuple émission, voire plus. Si le processeur émet une opération flottante, elle remplace une instruction entière ou mémoire. Prenons l'exemple des processeurs AMD K6, sortis en 1997, juste avant les AMD Athlon. Ils avaient 10 unités de calcul, 6 ports d'émission, 4 ports de décodage. Voici exactement comment étaient répartie les unités de calcul sur les ports d'émission. C'était des processeurs quadruple émission, ce qui fait que la moitié de la largeur du processeur sert pour des opérations entières, et l'autre moitié pour les accès mémoire. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 ! |- | ALU entière || ALU entière || FPU || Unité de branchement || Unité LOAD || Unité STORE |- | || Multiplieur || || || || Unité de calcul d'adresse (instruction LEA) |- | || ''Barrel shifter'' || || || || Unité pour les instructions PUSH |} Les micro-architectures K7, K8 et K10, utilisées sur les anciens CPU AMD Athlon, sont un exemple moins représentatif. Ces micro-architectures utilisent le partage des ports d'émission au maximum. C'était des micro-architectures à triple émission, avec six ports d’émission. Il y avait trois ports d'émission pour les opérations flottantes, et trois ports d'émission pour les micro-opérations entières/mémoire. Il y avait trois ALU entières et trois unités mémoire, chaque ALU partageait un port d'émission avec une unité mémoire. Elle pouvait donc émettre soit trois opérations entières, soit trois µops mémoire, soit un mix des deux. En plus des mix avec les opérations flottantes et branchements. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 ! |- | ALU entière || ALU entière || ALU entière || FADD || FMUL || FPU (autres opérations) |- | Multiplieur || || || || || |- | LOAD/STORE || LOAD/STORE || LOAD/STORE || || || |} Les CPU superscalaires larges dupliquent les multiplieurs et/ou les ''barrel shifters'', et même les unités de branchement. La raison est que quand la largeur du CPU augmente, le processeur traite des paquets d'instructions de plus en plus gros. Par exemple, pour un CPU à 10 voies, cela veut dire émettre 10 instructions en même temps. Il est alors plus fréquent d'avoir deux multiplications, deux décalages, deux accès mémoire, deux branchements. Par exemple, la moyenne est d'une multiplication tous les 5 instructions. Les CPU superscalaires à 10 voies ont donc tendance à avoir deux multiplications par paquet d'instruction, en moyenne. Avoir deux multiplieurs est alors un gain en performance clair. Par exemple, la micro-architecture ''Golden Cove'' a deux ''barrel shifters''. C'est une architecture à 6 voies. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 !! Port d'émission n°7 !! Port d'émission n°8 !! Port d'émission n°9 !! Port d'émission n°10 |- | ALU entière || ALU entière || ALU entière || ALU entière || ALU entière || LOAD || LOAD || LOAD || STORE || STORE |- | ''Barrel shifter'' || Multiplieur || Multiplieur || ''Barrel shifter'' || || || || || || |- | Branchements || Diviseur || || Branchements || || || || || || |} ==Les µops mémoire sur les processeurs superscalaires== Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. Voyons ces deux cas dans le détail. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ==Les processeurs à double émission== Dans cette section, nous allons étudier la forme la plus basique de superscalarité : les processeurs à double émission. De tels processeurs n'utilisent généralement pas l'exécution dans le désordre, mais il y a quelques exceptions. Les premiers CPU à double émission sont assez représentatifs des CPU à double émission modernes. Il faut dire que se limiter à deux voies ne permet pas de faire beaucoup de choses. En général, les processeurs à double émission ne dupliquent pas l'unité mémoire. Cela ne servirait à rien de faire deux accès mémoire simultanés sur un processeur à double émission. Et il en est de même pour la FPU et l'unité de branchement. Ils se limitent à deux techniques : dupliquer l'ALU entière, le partitionnement. Quant au partage des ports d'émission, c'est là que les ennuis commencent. L'implémentation la plus simple de la double émission utilise deux ports d'émission. Elle donne l'illusion que le processeur dispose de '''deux pipelines séparés'''. Ils partent d'un processeur non-superscalaire, et ajoutent un second pipeline, en sortie de l'unité d'émission. Le second pipeline permet l'émission parallèle d'une seconde instruction. Le problème est que les deux ports d'émission sont reliés à un grand nombre d'unités de calcul. L'unité d'émission doit donc partager des ports d'émission, et c'est là que les subtilités commencent. Il faut répartir les unités au mieux pour éviter d'avoir trop de contraintes d’appariement problématiques. Le second pipeline n'est pas identique au pipeline originel, sur plusieurs points. Premièrement, il n'a pas exactement les mêmes circuits. Le second pipeline est généralement composé d'une unité de calcul, et permet d'exécuter une seconde opération de calcul, pas plus. En conséquence, les contraintes d'appariement assez fortes. Et il n'a pas forcément la même longueur, il peut avoir des étages en moins ou en plus. Il s'agit de la forme la plus simple de processeur superscalaire et nous allons leur consacrer cette section. Une autre implémentation utilise de la double émission, mais avec 3, 4, 5, ... ports d'émission. Le processeur ne donne plus l'impression d'avoir deux pipelines séparés, mais gagne en flexibilité. Les contraintes d'appariement sont plus faibles, moins nombreuses, moins problématiques. Mais le cout en circuit n'est pas négligeable. Voici le tableau chronologique disant quel CPU utilisait quelle technique. * Les CPU en vert ajoutaient un second pipeline pour les opérations flottantes. * Les CPU en rouge ajoutaient un second pipeline pour les opérations entières. * Les CPU en jaune utilisaient un ''partitionnement complet'' entre ALU, FPU et unité mémoire. * Les CPU sans couleur utilisaient un grand nombre d'unités, avec beaucoup de ports d'émission. * Les CPU barrés utilisent la triple ou quadruple émission et seront vu dans la suite. {|class="wikitable" |- ! !! IBM !! HP PA-RISC !! SPARC !! Intel!! Alpha !! Autres |- ! 1989 | class="f_vert | RS/6000 || || || class="f_rouge | i960CA || || |- ! 1990 | || || || || || |- ! 1991 | || || || || || Motorola 88110 |- ! 1992 | class="f_vert | RSC || class="f_vert | PA 7100 || <s>SuperSPARC</s> || || class="f_jaune | 21064 || |- ! 1993 | class="f_vert | PPC 601 || || class="f_jaune | HyperSPARC || class="f_rouge | Pentium || || |- ! 1994 | <s>POWER 2</s> || PA 7200 || || || <s>21164</s> || |} ===La double émission entière-flottante=== L'exemple le plus simple utilise un second pipeline pour les opérations flottantes, séparé du pipeline originel. L'ajout d'un pipeline flottant permet d'émettre une micro-opération entière en même temps qu'une micro-opération flottante, ce qui s'appelle la '''double émission entière-flottante'''. La micro-opération entière s'exécute dans l'ALU entière, la micro-opération flottante dans la FPU. Les exemples les plus notables sont le PA-RISC 7100, le POWER 1 (RIOS .& et .9) et le PowerPC 601. L'Apollo DN10000 et l'Intel i860 utilisaient aussi cette forme de double émission, mais étaient des prototypes de CPU VLIW. [[File:Double émission entière-flottante.png|centre|vignette|upright=2|Double émission entière-flottante]] Pour bien expliquer les choses, prenons le processeur illustré ci-dessous, avec deux ''back-end'' séparés : un pour la FPU, et un autre pour le reste. Les instructions entières et mémoire passent toutes par le ''back-end'' généraliste. [[File:CPU non-superscalaire basique, avec une FPU.png|centre|vignette|upright=1.5|CPU non-superscalaire basique, avec une FPU]] Les deux ''back-end'' sont des pipelines séparés, mais il faut que l'unité d'émission soit spécialement conçue pour en profiter. Un processeur simple émission peut démarrer une seule µop par cycle : soit la µop est envoyée au ''back-end'' flottant, soit elle est envoyée au ''back-end'' général. Avec la double émission entière-flottante, le processeur peut envoyer une µop dans chaque pipeline, à chaque cycle. Vu que la FPU et son banc de registre forment déjà un pipeline séparé, les seules modifications se font au niveau du ''front-end'', unité d'émission inclue. {| |- |[[File:CPU simple émission avec une FPU.png|centre|vignette|upright=1.5|CPU simple émission avec une FPU.]] |[[File:Double émission entière flottante avec un second pipeline.png|centre|vignette|upright=1.5|CPU avec un second pipeline flottant.]] |} Notez que le terme double émission ''entière''-flottante est un peu trompeur. En réalité, la technique permet d'émettre une instruction flottante avec n'importe quelle autre instruction non-flottante, avec des restrictions pour les branchements. Il est par exemple possible d'exécuter un calcul flottant en parallèle d'un accès mémoire. Sur les CPU 32 bits, il est possible de faire les multiplications dans la FPU. Pour rappel, la FPU contient un multiplieur entier 53-54 bits, pour multiplier les mantisses de deux flottants 64 bits, qu'on peut utiliser pour faire les multiplications entières 32 bits. Cela permet d'émettre une multiplication en même temps qu'une opération entière dans l'ALU. Ainsi, le CPU pouvait émettre deux instructions : une opération entière de base, avec soit une multiplication, soit une opération flottante. En clair, une émission de type INT + (MUL /FLOAT). Par contre, on perd la possibilité d'émettre une multiplication entière avec une opération flottante, mais c'était un compromis qui en valait la peine. Le CPU PA-RISC 7100 implémentait cette optimisation. Il s'agissait d'un CPU double émission entière-flottante, qui utilisait donc un partitionnement assez simple. Le combo double émission entière-flottante + multiplication dans la FPU permettait ici de gagner en performance, pour un cout en matériel très limité. D’autant plus limité que le jeu d'instruction prévoyait le reconditionnement de la FPU. Les multiplications ne lisaient pas leurs opérandes dans les registres entiers, mais dans les registres flottants. ===La double émission entière=== Une autre possibilité ajoute un second pipeline, capable d'exécuter des opérations entières simples, appelé le pipeline entier. Le processeur supporte alors la '''double émission entière''', à savoir qu'ils peuvent émettre une opération entière en plus d'une autre instruction. L'opération entière en question est une opération simple, comme une addition, une soustraction, une opération bit à bit, une comparaison, etc. Un exemple de CPU double émission de ce type est celui du Power PC 440, un CPU destiné à l'embarqué. Vu qu'il était destiné à l'embarqué, il n'avait pas de FPU, il n'avait pas besoin de manipuler de nombres flottants. Il n'a donc que des ALU entières, un multiplieur et une unité mémoire. Il dispose de deux ports d'émission. Le premier est connecté à une ALU entière et un circuit multiplieur, le second est relié à l'unité mémoire et une seconde ALU entière. Pour résumer : une ALU entière par port, l'unité mémoire sur un port, le multiplieur sur l'autre. Une organisation simple, mais très efficace ! [[File:PowerPC 440.png|centre|vignette|upright=2|Micro-architecture du PowerPC 440.]] Le processeur précédent n'avait pas de FPU, ce qui fait qu'il n'avait pas grand chose à connecter sur ses ports d'émission. le partage des ports d'émission était assez simple. Aussi il est intéressant de voir des exemples avec une FPU. Les exemples classiques sont les processeurs Intel i960 et Pentium. L'implémentation était différente entre l'i960 et le Pentium : Le Pentium avait une seconde ALU entière, alors que le i960 rusait. Le Pentium dupliquait l'ALU entière, pour créer un second pipeline entier. Sur le Pentium originel, les contraintes d'appariement étaient assez strictes. Le Pentium avait une FPU, mais ne pouvait pas l'utiliser en double émission. Il lui était impossible d'exécuter une opération flottante en même temps qu'une opération entière (branchements inclus). Le Cyrix 6x86 était une sorte de Pentium produit par l'entreprise Cyrix, avec une micro-architecture différente. Il avait des contraintes d’appariement réduites. Par exemple, il pouvait émettre une opération flottante en parallèle d'une opération entière. Nous détaillerons ces processeurs dans le prochain chapitre, qui porte justement sur les CPU superscalaires x86. [[File:CPU simple et double émission avec un second pipeline entier.png|centre|vignette|upright=2.5|CPU simple et double émission avec un second pipeline entier.]] L'Intel i960 n'avait pas de FPU, juste une ALU entière et une unité mémoire. Il utilisait l'unité mémoire comme une seconde ALU entière limitée. Pour rappel, une unité mémoire contient une AGU, à savoir une ALU spécialisée dans les calculs d'adresse. Elle est capable de faire des additions, soustractions, et quelques décalages limités (des décalages par 2, 4, 8, pas plus). L'Intel i960 ajoutait des connexions entre AGU et banc de registre, afin que celle-ci serve de seconde unité de calcul. L'AGU est déjà reliée aux deux ports de lecture, pour lire les opérandes du calcul d'adresse, il suffit de la connecter sur le port d'écriture. Le processeur peut alors émettre deux instructions/µops entières, sous certaines conditions. [[File:Emission multiple des opérations entières, double émission.png|centre|vignette|upright=1.5|Émission multiple des opérations entières, double émission.]] le processeur alpha 21264 implémentait cette optimisation, mais c'était un processeur quadruple émission, aussi je le mentionne juste en passant. Il avait deux ALU entières et deux AGUs, les deux AGUs pouvaient faire des additions/soustractions et des opérations logiques. ===La double émission INT-FLOAT-MEM=== La '''double émission INT-FLOAT-MEM''' utilise la technique du partitionnement superscalaire, vue plus haut, avec cependant une petite subtilité. Elle utilise précisément une ALU entière, une FPU et une unité mémoire. En théorie, le partitionnement devrait donner un CPU triple émission, pas double émission. Et c'est là que la différence entre ports d'émission et de décodage devient intéressante. L'idée est d'avoir une unité d'émission avec deux ports de décodage, et 2 ports d'émission (un par unité). Les contraintes d’appariement varient alors suivant le processeur, mais la règle générale est que si deux micro-opérations tombent dans deux unités différentes, on peut les émettre en même temps. En clair, on peut émettre une opération entière avec une opération flottante, une opération entière avec un accès mémoire, une opération flottante avec un accès mémoire. Et cela rend le partitionnement bien plus performant que prévu. L'avantage sur la double émission flottante est que les combinaisons INT + MEM et FLOAT + MEM étaient autorisées. Le processeur pouvait donc émettre une opération en parallèle d'un accès mémoire, ce qui est un doublet d'instruction assez fréquent. Le gain en performance par rapport à la doublé émission entière-flottante était substantiel. Un détail est que cette forme de double émission ne peut pas s'interpréter comme un processeur à deux pipelines. Le processeur n'a pas deux ports d'émission, mais trois, et les contraintes d'appariement collent assez mal avec deux pipelines séparés. Il est possible d'interpréter le tout comme un CPU à trois pipelines, mais qui n'autorise que les paires d'instructions, mais ce serait assez tiré par les cheveux. Aussi, la notion de "plusieurs pipelines indépendants" ne marche pas pour décrire ce genre de processeurs superscalaires. Leur nombre de voies reste de deux, mais le nombre de pipelines est indéterminé. Les exemples classiques sont l'HyperSPARC, l'alpha 21064 et le PowerPC 603. Le PowerPC 603 implémentait une double émission "parfaite". C'est à dire que toutes les combinaisons possibles à deux instructions étaient possibles, que ce soit INT + FLOAT, INT + MEM, FLOAT + MEM. En plus de cette double émission "parfaite", le CPU utilisait la technique dite de ''branch folding'', qui permettait d'exécuter un branchement sans utiliser d'unité de calcul, ce qui fait qu'il était qualifié de ''CPU 2-way + Branch''. A l'opposé, le processeur alpha 21064 avait pas mal de contraintes d’appariement, à savoir que certaines combinaisons de deux instructions étaient interdites, bien que compatibles avec les unités disponibles. Une des raisons est que le banc de registre n'avait pas assez de ports de lectures, ce qui fait que certaines combinaisons avec une écriture mémoire n'étaient pas permises. Les concepteurs avaient volontairement inséré ces contraintes afin de simplifier le design du processeur. ===La double émission avec partitionnement et duplication d'ALU entière=== Nous venons de voir qu'il est possible d'utiliser efficacement un surplus de ports d'émission, afin de réduire les contraintes d'appariement. Des ports d'émission qui étaient partagés sont simplement dupliqués et les ALU sont réparties sur des ports d'émission différents. Il est possible de pousser cette logique encore plus loin, en utilisant un grand nombre d'unité de calcul. L'exemple historique le plus impressionnant est celui du Motorola 88110, un CPU double émission qui possède pas moins de 10 unités ! Il contient deux ALU entières, un circuit multiplieur, un circuit diviseur, une unité de manipulation de bits, une unité mémoire, un additionneur flottant, et deux unités graphiques pour des calculs impliquant des pixels. Le multiplieur est utilisé à la fois pour les multiplications entières, flottantes et le produit de deux pixels. Idem pour le diviseur, qui sert pour les divisions entières et flottantes. Et le partitionnement est poussé au point où chaque unité a son propre port d'émission. La conséquence est que les contraintes d'appariement du Motorola 88110 sont simples : tant que deux µops atterrissent dans des unités séparées, la paire est permise. Il est par exemple possible d'émettre simultanément deux opérations entières simples, vu qu'il y a deux ALU. Ou encore, d'émettre une addition et une multiplication, une multiplication et un décalage, un décalage et une division, une addition entière et une addition flottante, etc. Par contre, impossible d'émettre simultanément deux multiplications, deux divisions, deux opérations flottantes, deux manipulations de bit, deux accès mémoire, etc. De plus, le partage du multiplieur fait qu'on ne peut pas émettre une multiplication entière avec une multiplication flottante. Idem avec les divisions, vu que le diviseur est aussi partagé. Ce sont les seules contraintes, et c'est très peu comparé aux autres processeurs double émission. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le parallélisme mémoire | prevText=Le parallélisme mémoire | next=Les processeurs superscalaires out-of-order | nextText=Les processeurs superscalaires out-of-order }} </noinclude> rv0pd0ji2wjf09yh8qx45xpob5now09 774169 774168 2026-10-04T19:28:03Z Mewtow 31375 /* La double émission avec partitionnement et duplication d'ALU entière */ 774169 wikitext text/x-wiki Les processeurs vus auparavant ne peuvent émettre au maximum qu'une instruction par cycle d'horloge. Ils peuvent avoir plusieurs instructions qui s'exécutent en même temps, dans des unités de calcul séparées. C'est le cas dès qu'une instruction multi-cycles s'exécute. Par contre, ils ne peuvent démarrer qu'une seule instruction par cycle. 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'''. Un processeur à émission multiple charge plusieurs instructions en même temps, les décode en parallèle, puis les émet en même temps sur des unités de calculs séparées. Pour cela, 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. En général, pour un processeur à émission multiple capable d'émettre N instructions en parallèle, on parle de '''processeur à N voies''', ou encore un ''processeur à N pipelines''. [[File:Superscalarpipeline.svg|centre|vignette|upright=1.5|Pipeline RISC classique à cinq étages sur un processeur superscalaire. On voit bien que plusieurs instructions sont chargées en même temps.]] 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 qu'ils ont un jeu d'instruction spécialisé qui expose le parallélisme d'instruction directement au niveau du jeu d'instructions, là où les processeurs superscalaires restent des processeurs au jeu d'instruction normal. La différence principale entre processeur VLIW et superscalaire 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. Nous allons voir uniquement les processeurs superscalaires dans ce chapitre. Et plus précisément ceux sans exécution dans le désordre. Un processeur superscalaire peut utiliser l'exécution dans le désordre, mais ce n'est pas une obligation. Historiquement, les premiers processeurs superscalaires étaient des processeurs ''in-order'' (ce qui veut dire qu'ils n'utilisaient pas d'exécution dans le désordre). Le meilleur exemple est celui du Pentium 1 et 2 d'Intel. Ils étaient tous deux des processeurs superscalaires ''in-order''. Dans ce chapitre, nous allons voir uniquement les processeurs superscalaires ''in-order'' pour simplifier les explications. Il faut cependant garder à l'esprit que les explications qui vont suivre marchent aussi pour les processeurs superscalaires ''out of order'', sauf pour quelques exceptions. Aussi, ce chapitre servira d'introduction au chapitre suivant. ==Le nombre de voies d'un processeur superscalaire== En général, les processeurs superscalaires ''in-order'' 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 triple émission permettent eux d’émettre trois instructions à la fois, mais ils sont plus rares. A l'opposé, les CPU superscalaires les plus puissants peuvent émettre jusqu'à 8-10 instructions simultanément. En 2026, les CPU superscalaires à exécution dans le désordre peuvent monter jusqu’à 10 µops émises en même temps, ce qui est énorme ! Et cela nous amène à faire une distinction entre les processeurs superscalaires dits '''étroits''' et ceux dits '''larges'''. Dans ce wikilivre, on part du principe que les processeurs superscalaires étroits émettent jusqu'à 4-5 µops à la fois, alors que les larges peuvent en émettre plus, l'intervalle entre les deux est une zone grise. Le nombre exact n'est pas fixé dans le marbre, la distinction est volontairement vague. Nous parlerons de la '''largeur d'un CPU superscalaire''' pour parler du nombre de voies, du nombre de µops émises en même temps. Plus un CPU superscalaire est large, plus il est performant ''toutes choses égales par ailleurs''. Mais dans les faits, vous pouvez oublier la partie en italique. Élargir un CPU superscalaire demande de rajouter beaucoup de circuits. Et ce cout en circuit aura un impact sur la performance. À budget en transistors limité, il y a un compromis entre élargir un CPU et rajouter du cache ou la taille des ''schedulers''/ROB et autres. ===Les contraintes d’appariement=== Prenons un processeur double émission, capable d'émettre deux instructions simultanément. Émettre deux instructions en même temps n'est pas toujours possible. Par exemple, si deux instructions sont dépendantes, on ne peut pas les émettre en même temps. Mais oublions un instant ces histoires de dépendances de données, et partons du principe que le CPU ait affaire à deux instructions indépendantes, une paire idéale. Un CPU double émission "parfait" pourrait exécuter n'importe quelle paire d'instruction. Par exemple, il pourrait émettre deux multiplications consécutives, deux lectures consécutives, une lecture et une écriture en même temps, etc. En pratique, les processeurs qui font ça sont rare. Implémenter un CPU double émission "parfait" a un cout en circuit conséquent. Par exemple, pour émettre deux multiplications en même temps, il faudrait deux multiplieurs séparés. Idem pour exécuter deux accès mémoire simultanés : cela demande d'avoir deux unités mémoire et un cache multiport. La totalité des CPU double émission se débrouille avec un seul circuit multiplieur et une seule unité mémoire. Et cela entraine l'apparition de dépendances structurelles, qui font que certaines paires d'instructions sont impossibles. Par exemple, impossible d'émettre deux multiplications en même temps avec un seul circuit multiplieur. Mais émettre une multiplication en parallèle d'une addition est parfaitement possible. Il y a donc des paires d'instructions autorisées et des paires interdites. Pour le dire autrement, il y a des '''contraintes d'appariement''' sur les instructions à émettre en même temps. Ces contraintes d’appariement ont un impact sur les performances, mais celui-ci peut être grandement mitigé. Les programmes informatiques utilisent en pratique beaucoup plus d'opérations entières de base que de multiplication, branchements et accès mémoire. Il y a environ 5 opérations entières de base pour une multiplication entière. En pratique, il est rare de devoir exécuter deux multiplications à la fois. Idem pour les décalages et rotations, qui sont assez éloignés les uns des autres. Deux branchements consécutifs sont quant à eux plus fréquents. Deux accès mémoire consécutifs est assez rare, mais ils restent assez proches les uns des autres malgré tout. La conséquence est que certaines paires d'instructions sont privilégiées par rapport au reste. Les paires regroupant deux calculs basiques (additions, soustractions, ...) sont systématiquement supportées, car dupliquer des ALU entières est facile. Les paires regroupant un calcul et une autre instruction sont elles aussi systématiquement supportées. Par contre, les paires avec deux branchements, deux accès mémoire, deux décalages/rotations, ou deux multiplications/divisions sont fréquemment interdites. Seuls les CPU superscalaires larges peuvent les permettre, et encore : sous conditions. Le résultat est que le processeur peut se débrouiller avec une seule unité mémoire et une unité de branchement. Décrire les contraintes d’appariement demande de dire quelles combinaisons d’instructions sont autorisées ou interdites. Et pour cela, nous allons introduire les abréviations suivantes : * INT est un raccourci pour parler d'une opération entière simple, qu'une ALU entière peut exécuter. * MUL correspond à une opération entière complexe, typiquement une multiplication, éventuellement à une division. * FLOAT désigne une opération flottante, peut importe laquelle. * MEM désigne un accès mémoire, à savoir une instruction ou µop mémoire. * BRANCH désigne une instruction de branchement. Cette terminologie permettra de définir des paires d'instructions, qu'elles soient possibles ou interdites. Par exemple, l'expression INT + FLOAT désigne une paire d'instruction regroupant une instruction entière et une instruction flottante, peu importe l'ordre des deux instructions. Il en sera de même pour les triplets, quadruplets, ou plus. Par exemple, l'expression INT + INT + FLOAT + MUL désigne un quadruplet d'instructions regroupant : deux instructions entières, une instruction flottante, et une multiplication entière. J'utiliserais aussi des parenthèses pour regrouper certaines combinaisons. Par exemple, l'expression INT + (MUL /FLOAT) veut parler d'une paire d'instruction où la première est une opération entière basique, la seconde est : soit une multiplication, soit une opération flottante. ===L'historique simplifié de la superscalairité=== L'histoire des CPU superscalaires a commencé dès 1960, mais est resté un projet de recherche pendant plusieurs décennies. De nombreux designs ont été conçus, mais abandonnés avant la production, comme l'ACS-1 d'IBM. Les années 1980 ont vu l'arrivée de prototypes de processeurs VLIW, mais qui n'étaient pas superscalaires. Par exemple, l'Apollo DN10000 et l'Intel i860 pouvaient émettre deux instructions à la fois, sous conditions, mais demandaient que le compilateur fasse une bonne partie du travail. Les premiers CPU superscalaires commercialisés sont sortis en 1989. Deux CPUs superscalaires sont sortis cette année-là : le RS/6000 d'IBM et l'i960CA d'Intel. Puis, la compétition s'est aussi lancée dans la bataille. Les CPU commercialisés avant l'année 1993 étaient à double émission, à l'exception du SuperSPARC qui utilisait la triple émission. Les modèles après 1993 hésitaient entre double et quadruple émission. Dans le tableau ci-dessous, les processeurs quadruple émission sont en bleu, les triple émission sont en rouge, les autres ne sont pas en couleur. {|class="wikitable" |- ! !! IBM !! HP PA-RISC !! SPARC !! Intel!! Alpha !! Autres |- ! 1989 | RS/6000 || || || i960CA || || |- ! 1990 | || || || || || |- ! 1991 | || || || || || Motorola 88110 |- ! 1992 | RSC || PA 7100 || class="f_rouge | SuperSPARC || || 21064 || |- ! 1993 | PPC 601 || || HyperSPARC || Pentium || || |- ! 1994 | class="f_bleu | POWER 2 || PA 7200 || || || class="f_bleu | 21164 || |- ! 1995 | 603/604 || class="f_bleu | PA 8000 || class="f_bleu | UltraSPARC 1 || Microarchitecture P6 (+ concurrence AMD) || || class="f_bleu | MIPS R1000 |- ! 1996 | class="f_bleu | 620 || || || || class="f_bleu | 21264 || |- ! ... || ... || ... || ... || ... || ... || ... |} ==Le ''front-end'' des processeurs superscalaires== Un processeur superscalaire doit charger, décoder, et exécuter plusieurs instructions par cycle. Par exemple, un processeur triple émission doit charger 3 instructions lors d'un premier cycle, puis les décoder au cycle suivant, et les exécuter au cycle suivant. Il travaille donc pas paquets de 3 instructions, dans cet exemple. Si c'est un processeur superscalaire ''in-order'', cela donne l'impression que le pipeline dispose de plusieurs '''pipelines séparés'''. Intuitivement, charger, décoder et exécuter plusieurs instructions à la fois demande de dupliquer des circuits. Par exemple, il faut dupliquer les décodeurs pour décoder plusieurs instructions. Les unités de calcul sont aussi dupliquées, sauf sur certains processeurs qui arrivent à ruser, mais laissons cela de côté pour plus tard. Mais d'autres circuits sont simplement modifiés, comme l'unité de chargement ou l'unité d'émission. Voyons ce qu'il en est dans le détail. {|class="wikitable" |- ! rowspan="2" | Processeur sans émission multiple | rowspan="2" | Chargement | rowspan="2" | Décodage | rowspan="2" | Émission | Exécution / ALU | rowspan="2" | ''Commit''/ROB |- | Exécution / ALU |- ! rowspan="4" | Processeur superscalaire | rowspan="4" | Chargement | rowspan="2" | Décodage | rowspan="4" | Émission | Exécution / ALU | rowspan="4" | ''Commit''/ROB |- | Exécution / ALU |- | rowspan="2" | Décodage | Exécution / ALU |- | Exécution / ALU |} Nous allons d'abord voir ce qu'il en est pour le ''front-end'', à savoir la partie du CPU qui s'occupe de charger et décoder les instructions. Elle regroupe l'unité de chargement, les décodeurs, l'unité de prédiction de branchement, la file de µops, la file d'instruction, et divers caches. En soi, les changements portent surtout sur l'unité de chargement et les décodeurs. ===Le chargement de plusieurs instructions consécutives=== Un processeur superscalaire charge plusieurs instructions en même temps, ''en tenant compte de la présence de branchements''. La partie en italique est importante et elle a une conséquence très forte : la manière de faire est fortement différente entre un CPU superscalaire étroit et large. Un CPU superscalaire étroit fait au plus simple : il charge un bloc de 2, 3, 4 instructions consécutives en mémoire RAM. Il faut alors doubler/tripler/quadrupler la taille du bus connecté au cache d'instruction. Par exemple, pour charger quatre instructions de 32 bits chacune, il suffit de lire un bloc de 128 bits dans le cache d'instruction. Un bloc de 8, 16, 32 octets est donc lu depuis le cache et est ensuite découpé en instructions, envoyées chacun à un décodeur. Charger plusieurs instructions consécutives ne marche bien qu'en absence de branchements. La présence de branchements casse cette dynamique, et il faut gérer leur présence. * Sans prédiction de branchement, toutes les instructions d'un bloc sont décodées et exécutées en même temps, même s'il y a un branchement dans le tas. * Avec prédiction de branchement, les instructions situées après le branchement ne sont pas décodées ni exécutées. L'unité de chargement coupe le bloc chargé au niveau du premier branchement pris, remplit les vides avec des NOP, avant d'envoyer le tout à l'unité de décodage. Les instructions à l'adresse de destination seront chargées au cycle suivant, en chargeant un nouveau bloc. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un autre défaut est que sur le CPU charge un bloc de 128 bits à la fois, les instructions doivent être alignées sur 128 bits. Et on peut généraliser à n'importe quelle taille de bloc. Si les instructions ne sont pas alignées, il faut gérer la situation. Il arrive qu'une instruction soit à cheval sur deux blocs, avec un morceau dans le premier bloc, un autre dans le second. Dans ce cas, l'instruction est chargée en deux fois, et des circuits doivent gérer la situation pour recoller les morceaux. Sur les CPU avec des instructions de taille fixe, le problème ne survient que rarement. Mais il est fréquent avec des instructions de taille variable. Un autre défaut est lié aux branchements. Imaginez qu'un branchement s'exécute et branche vers une instruction de destination. Rien n'indique que l'instruction de destination sera au tout début d'un bloc. Si ça se trouve, elle sera en troisième position d'un bloc de 4 instructions. Une solution simple charge le bloc en respectant l'alignement, et les deux premières instructions du bloc ne sont pas à exécuter. Il y a donc une petite perte de performance. Certains CPU, comme l'IBM RS/6000, ont la machinerie charger un bloc de N instructions même s'il n'est pas aligné. La machinerie en question se trouve dans le cache d'instruction et est tout sauf simple à expliquer, ce qui fait que je me permets de la passer sous silence. Un détail, cependant : elle ne permet pas de charger des blocs qui sont à cheval sur deux lignes de cache. ===Le décodage parallèle des instructions=== Pour le décodage des instructions, plusieurs instructions sont décodées en même temps. On parle alors de '''décodage parallèle'''. Pour ce faire, un processeur superscalaire contient plusieurs décodeurs, chacun pouvant décoder une instruction en parallèle des autres. Prenons par exemple un processeur superscalaire à 4 voies : le décodage se fera dans quatre décodeurs séparés, qui fonctionneront en parallèle. Cependant, cette implémentation ne marche bien que pour les processeurs RISC, les processeurs CISC devant faire avec un microcode gourmand en circuits. Les processeurs CISC utilisent des décodeurs hybrides, avec un microcode qui complémente un décodeur câblé. Dupliquer le microcode aurait un cout en transistors trop important, ce qui fait que seuls les décodeurs câblés sont dupliqués. Les CPU CISC superscalaires disposent donc de plusieurs décodeurs simples, capables de décoder les instructions les plus courantes, avec un seul microcode. La conséquence est qu'il n'est pas possible de décoder deux instructions microcodées en même temps. Par contre, il reste possible de décoder plusieurs instructions non-microcodées ou une instruction microcodée couplée à une instruction non-microcodée. Vu qu'il est rare que deux instructions microcodées se suivent dans un programme, le cout en performance est extrêmement mineur. Il faut noter qu'une instruction correspond à une ou plusieurs µops. Il est donc possible de charger 5 instructions et de se retrouver avec 8 µops en sortie des décodeurs. Et c'est la source d'une distinction très importante. Plus haut, j'ai parlé de processeurs superscalaires à N voies. La terminologie peut avoir deux interprétations. La première est que le processeur charge/décode N instructions à la fois. Une seconde est qu'il est capable d'exécuter/émettre N µops en même temps. Les deux ne sont pas équivalentes, vu qu'un processeur superscalaire peut, par exemple, charger/décoder N instructions et émettre/exécuter N+2 µops. La distinction est particulièrement importante sur les processeurs superscalaires larges. Certains d'entre eux sont capables de charger 5 instructions et d'en émettre le double ! Un exemple classique était les processeurs Athlon d'AMD, qui pouvaient décoder deux instructions par cycle, mais émettre 4 µops par cycle. Ce n'était pas un problème, car beaucoup d'instructions, même basiques, étaient décodées en 2 µops. ===L'émission parallèle des instructions=== La superscalarité modifie en profondeur l'unité d'émission, notamment au niveau de son interface. Avant, elle recevait une micro-opération sur son entrée, et fournissait la micro-opération émise sur une sortie. Et cela vaut aussi bien pour une unité d'émission simple, un ''scoreboard'', un ''scheduler'' 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. Dans ce qui suit, nous ne parlerons pas d'entrée et de sortie, mais de '''ports de décodage''' et de '''ports d'émission'''. Les ports de décodage reçoivent des micro-opérations provenant de l'unité de décodage, les ports d'émissions sont pour les micro-opérations émises. Les ports d'émission injectent les micro-opérations émises dans le pipeline, elles sont propagées au banc de registre, aux ALUs, etc. Un processeur superscalaire a forcément plusieurs ports d'émission (et de décodage). [[File:Ports de décodage et d'émission.png|centre|vignette|upright=2|Ports de décodage et d'émission]] Un détail important est que l'unité d'émission peut avoir plus de ports d'émission que de ports de décodage. Et n'allez pas croire qu'un surplus de ports d'émission est du gâchis. Sur les processeurs avec exécution dans le désordre, ce surplus peut être utilisé pour émettre une µops sur chaque port d'émission par cycle d'horloge, ce qui donne un gain de performance appréciable. Ceci dit, les processeurs sans exécution dans le désordre utilisent aussi des ports d'émission en surplus. La raison sera expliquée dans quelques sections, mais nous pouvons dire que cela simplifie la conception du processeur. Si l'unité d'émission a un surplus de ports d'émission, on doit alors distinguer sa ''largeur de décodage'' et sa ''largeur d'émission''. La '''largeur de décodage''' est le nombre de µops fournies par l'unité de décodage par cycle d'horloge, la '''largeur d'émission''' est le nombre maximal de µops émises par cycle d'horloge. La largeur d'émission est égale ou supérieure à la largeur de décodage, tout dépend du processeur. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] Si l'interface de l'unité d'émission change, l'intérieur est tout autant modifié. Sur un CPU non-superscalaire, l'unité d'émission reçoit une µop "candidate à l'émission", et détecte les dépendances entre avec les µops en vol, en cours d'exécution dans le pipeline. Et c'est toujours le cas sur les processeurs superscalaires : l'unité d'émission détecte les dépendances entre les µops candidates et les µops en vol. Mais avec la siperscalarité, elle doit en plus tester les dépendances entre les µops candidates. Si l'unité d'émission est un ''scoreboard'', il doit détecter les dépendances entre instructions émises simultanément et les sérialiser si besoin. Avec un ''scheduler'', il n'y a pour ainsi dire rien à faire, vu que les dépendances sont éliminées par le renommage de registre et que les signaux de réveil gèrent les dépendances RAW. C'est la raison pour laquelle les processeurs superscalaires modernes n'utilisent pas de ''scoreboard''. ==Le ''back-end'' des processeurs superscalaires== Posons-nous cette question : de combien d'unités un processeur superscalaire a besoin ? Les unités en question ne sont pas nécessairement des unités de calcul, l'unité mémoire et l'unité de branchement comptent aussi. La réponse dépend du nombre de voies. Si le ''front-end'' charge et décode un paquet de N instructions, exécuter ces N instructions simultanément demandera là aussi au moins N unités de calcul. Notez le terme : ''au moins'', car c'est là tout l'enjeu de cette section. La réponse dépend aussi des contraintes d'appariement. Pour un CPU superscalaire "parfait", sans contraintes d’appariement, toutes les unités doivent être dupliquées, y compris l'unité mémoire. Un processeur à double émission parfaite devrait dupliquer l'ALU entière, le ''barrel shifter'', le circuit multiplieur, la FPU, l'unité mémoire, et l'unité de branchement, ce qui ferait passer de 6 unités à 12. Le cout en circuit est énorme, même pour de la double émission ! Et pour un CPU à N pipelines, le cout en circuit encore plus prohibitif ! En pratique, les processeurs superscalaires qui font ça sont très rares. La majeure partie des processeurs superscalaires utilisent d'autres techniques : le partitionnement, la duplication partielle des ALUs, la duplication des autres unités. ===Le partitionnement des unités fonctionnelles=== Les CPU superscalaires les plus simples ne dupliquent pas leurs unités de calcul, ils se contentent d'exploiter les unités existantes. Et pour expliquer comment, nous allons partir de l'exemple d'un processeur non-superscalaire contient une ALU entière, une FPU, une unité mémoire et une unité de branchement. Les quatre unités peuvent fonctionner en parallèle, à savoir qu'une unité peut exécuter une instruction à elle toute seule, sans interagir avec les autres. Cela suffit pour créer un processeur quadruple émission, qui peut émettre en même temps : une opération entière, une opération flottante, un branchement, un accès mémoire. Le principe marche aussi sur un processeur avec moins de 4 unités, par exemple sans FPU et/ou sans unité de branchement, ou avec au contraire des unités en plus. L'idée générale est d’émettre une µops séparée dans chaque unité fonctionnelle. Utiliser ainsi les unités existantes, pour rendre un CPU superscalaire, s'appelle le '''partitionnement superscalaire'''. Il consiste à attribuer un port d'émission à chaque unité existante. Le terme n'est pas beaucoup utilisé en dehors de quelques vieux articles académiques. Le partitionnement est fortement dépendant du jeu d'instruction, car il demande de bien séparer instructions entières, flottantes, branchements et instructions mémoire. Et autant c'est bien le cas sur les CPU RISC, autant les CPU CISC ne sont pas dans ce cas. Les CPU CISC ont des instructions ''load-op'', qui effectuent une instruction arithmétique et une µop mémoire. Et ces instructions sont très compliquées à implémenter avec le partitionnement. Et au-delà de ça, les CPU CISC ont beaucoup d'instructions qui sont décodées en plusieurs µops, à exécuter dans un ordre bien précis. On peut pas émettre les µops en question simultanément. Le partitionnement a un cout en circuit très limité, pour un gain en performance lui aussi limité. Pour reprendre le CPU de l'exemple précédent, il est rare que l'on ait à exécuter ces quatre instructions en même temps. En pratique, les combinaisons fréquentes sont les combinaisons calcul + accès mémoire, calcul + branchement, calcul entier + calcul flottant. Utiliser une quadruple émission de ce type n'est donc pas une bonne idée, alors que la double émission parait plus adaptée. Avant de poursuivre, précisons que la FPU regroupe un additionneur flottant et un multiplieur flottant. Et ce fait peut être exploité pour approfondir le partitionnement. La '''double émission FADD/FMUL''' permet d'émettre une addition flottante en même temps qu'une multiplication flottante. Elle a été utilisée sur les processeurs x86, Alpha et SPARC. Les autres circuits de calcul flottant sont souvent regroupés avec l'additionneur. La division est parfois e même port que le multiplieur flottant, parfois sur celui de l'additionneur (quand elle est microcodée et exécutée en enchainant les soustractions). ===La duplication des ALU entières=== Le partitionnement est une technique élégante, simple, facile à implémenter et au cout en transistors modeste. Mais il faut avouer que cela ne donne pas des CPU superscalaires très efficaces. Le partitionnement permet d'émettre des paires, triplets, quadruplets de micro-opérations qui sont rares en pratique. Et les paires, triplets, quadruplets d'instructions courants ne sont pas supportés. Les contraintes d’appariement sont trop strictes. Les combinaisons les plus fréquentes en pratique sont des paires/triplets d'opérations entières, souvent des paires/triplets d'additions ou d'opérations entières simples. Il est donc primordial d'émettre plusieurs opérations entières simultanément. Le fait d'émettre et d'exécuter plusieurs opérations entières en même temps s'appelle l''''émission entière multiple'''. Pour exécuter la seconde/troisième opération entière, il faut dupliquer les ALU entières (ou réutiliser l'AGU comme ALU entière comme le fait l'Intel i960). Le processeur doit avoir deux ALU entières pour de la double émission entière, trois pour de triple émission entière, etc. De plus, il faut ajouter des ports au banc de registre, pour lire deux fois plus d'opérandes et décrire deux résultats. Et enfin, il faut rajouter un port d'émission pour pouvoir émettre une micro-opération entière en plus. [[File:Processeur non-superscalaire basique 03.png|centre|vignette|upright=2|Processeur non-superscalaire basique]] La tendance est de multiplier les ALU entières, mais pas le circuit multiplieur. Le cout en transistors est alors grandement réduit, au prix de l'apparition de dépendances structurelles. Le CPU peut émettre deux additions en même temps, ou une addition et une multiplication, mais pas deux multiplications. Il en est de même avec le ''barrel shifter'', qui n'est pas dupliqué. Il est donc possible d'émettre un décalage avec une opération de base, mais pas deux décalages en parallèle. Mais ces contraintes d’appariement ne sont pas un problème, car il est rare de devoir émettre deux multiplications/décalages en même temps. La plupart des programmes sont majoritairement remplis d'additions, avec des multiplications assez rares et des décalages qui le sont encore plus. La moyenne est proche d'une multiplication pour 4/5 additions. Vu que des multiplications consécutives sont très rares, dupliquer le multiplieur est presque inutile. Disposer de plusieurs circuits multiplieurs ou de plusieurs ''barrel shifters'' serait donc un cout en circuits qui ne servirait que rarement et n'en vaut pas la chandelle. Il faut noter que sur les CPU 32 bits, les multiplications peuvent être exécutées dans la FPU ou dans un multiplieur séparé, tout dépend de si le CPU exécute les multiplications dans la FPU ou pas. {|class="wikitable" |- ! Voie d'émission n°1 !! Voie d'émission n°2 !! Voie d'émission n°3 |- | ALU entière || ALU entière || FPU |} Reste à connecter les unités de calcul à l'unité d'émission (indirectement, car il y a des étages de pipeline entre les deux). Une implémentation basique utilisée un port d'émission par unité. Prenons l'exemple d'un CPU superscalaire avec 2 ALU entières, un multiplieur entier, un ''barrel shifter'' une FPU, et une unité mémoire. Vu qu'il y a 6 unités, cela fait donc 6 ports d'émission. C'est beaucoup, l'unité d'émission est très complexe. Suffisamment complexe pour se demander s'ils n'existerait pas des optimisations qui permettraient d'en réduire le nombre sans trop réduire les performances, ni le nombre d'ALU. Et cela nous amène à la section suivante. ===Le partage des ports d'émission=== La présence d'un circuit multiplieur et d'un ''barrel shifter'' pose des problèmes pour le partitionnement. Il est possible d'avoir un port d'émission dédié au multiplieur, et un autre au ''barrel shifter'', qui sont séparés de ceux pour l'ALU entière. Il est alors possible d'émettre une opération entière, une multiplication et un décalage, simultanément. Nous parlerons de '''partitionnement entier''' pour désigner cette possibilité. [[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]] Le partitionnement entier est cependant très rare, car le gain en performance serait trop faible au regard des couts en circuit. Quelques processeurs historiques l'utilisaient, mais ce n'est plus le cas de nos jours. La raison est que les multiplications et les décalages sont assez rares dans le code, ce qui fait que le port d'émission associés sont sous-utilisés. Et un port d'émission a un cout en circuit assez important. Et c'est sans compter qu'ajouter des ports sur le banc de registres juste pour un multiplieur sous-utilisé n'en vaut pas la peine. L'idéal serait de trouver un moyen de réduire le nombre de ports d'émissions, sans réduire le nombre d'unités de calcul. [[File:Emission multiple des opérations entières, implémentation naive.png|vignette|Émission multiple des opérations entières, implémentation naive]] La solution consiste à '''partager les ports d'émission''', à savoir connecter un port d'émission à plusieurs unités de calcul. L'exemple classique est justement lié aux circuits multiplieur et ''barrel shifters''. L'idée est que le multiplieur et le ''barrel shifter'' sont mis sur le même port d'émission qu'une ALU entière. Prenons par exemple le processeur à double émission entière illustré ci-contre. Un port d'émission est relié à une ALU entière seule, et l'autre port d'émission alimente la seconde ALU entière et le multiplieur. La conséquence est que le processeur peut émettre deux opérations entières, dont une multiplication. Si on avait utilisé un port d'émission dédié au multiplieur, il aurait été possible d'émettre deux opérations entières et une multiplication. On perd donc une voie, mais on gagne en flexibilité et on économise des circuits. Notons que ce partage signifie aussi que le multiplieur partage les ports sur le banc de registre, ce qui fait une double économie. [[File:Emission multiple des opérations entières, implémentation courante.png|centre|vignette|upright=1.5|Émission multiple des opérations entières, implémentation courante]] En général, le ''barrel shifter'' et le multiplieur sont placés sur deux voies d'émission séparés, ce qui permet d'émettre un décalage et une multiplication en même temps. Les mettre sur le même port ne le permettrait pas. {|class="wikitable" |- ! Voie d'émission n°1 !! Voie d'émission n°2 !! Voie d'émission n°3 |- | * ALU entière * Multiplieur | * ALU entière * Barrel shifter | FPU |- | * Additions, soustractions, opérations bit à bit, etc. * Multiplication | * Additions, soustractions, opérations bit à bit, etc. * Décalages et rotations | Opération flottante |} Terminons concernant les multiplieurs avec une dernière subtilité. Pour rappel, les processeurs modernes intègrent en réalité deux multiplieurs différents. Le résultat d'une multiplication est très large et prend deux registres. Par exemple, une multiplication de deux opérandes 64 bits donne un résultat sur 128 bits, ce qui ne tient pas dans un registre, mais en prend deux. L'instruction de multiplication classique ne calcule que les 64 bits de poids faible. mais elle est complétée par d'autres instructions de multiplication qui peuvent faire le calcul complet. Pour résoudre le problème, les processeurs modernes intègrent deux instructions de multiplications : une qui calcule les bits de poids faible du résultat, une autre qui calcule les bits de poids fort. Et ces deux instructions sont associées à deux multiplieurs séparés, aux circuits différents. Le multiplieur qui calcule les bits de poids faible du résultat est appelé le ''low multiplier'', l'autre est appelé le ''high multiplier''. Le second multiplieur est souvent placé sur un second port d'émission, différent du premier multiplieur, partagé avec une ALU. L’avantage de mettre les deux multiplieurs sur deux ports d'émission différents est qu'une multiplication complète peut être réalisée en utilisant les deux multiplieurs en parallèle. Les deux sous-multiplications sont émises en même temps, exécutées en même temps. pas besoin d'exécuter l'une puis la seconde, d'où un gain performance appréciable. Nous venons de voir comment partager un port d'émission entre une ALU et un multiplieur. Mais au-delà de cet exemple, le partage des ports d'émission peut appliquer à n'importe quelle unité. Prenons l'exemple d'un CPU superscalaire avec 4 ALU entières, une FPU, et une unité mémoire. Vu qu'il y a 6 unités, cela fait donc 6 ports d'émission. Cependant, il est rare qu'un bloc de 6 instructions contienne exactement 4 opérations entières, une opération flottante, et un accès mémoire. Beaucoup de ports d'émission seraient sous-utilisés. La solution est de partager des ports d'émission, pour passer à la quadruple émission. Le CPU peut alors répartir 4 instructions sur les ports d'émission adéquats, à chaque cycle. Le CPU a donc 4 ports de décodage et 6 ports d'émission. Une implémentation classique permet n'importe quelle combinaison de 4 instructions compatibles avec les 6 unités. Par exemple, il devient possible d'émettre simultanément 4 additions, la combinaison 2 * INT + FLOAT + MEM, 3 * INT + MEM, et bien d'autres. L'exemple relie une ALU entière, la FPU et l'unité mémoire sur le même port. Le CPU a alors quatre ports d'émission, qui ne sont pas "identiques". Trois d'entre eux sont reliés à une ALU entière chacun, rien d'autre. Le quatrième est lui relié à une ALU entière, la FPU et l'unité mémoire. On économise alors deux ports d'émission, mais cela entraine l'apparition de contraintes d’appariement. Le CPU peut émettre 3 opérations entières + n'importe quelle autre opération. Mais les autres combinaisons sont interdites. Le CPU peut émettre 6 opérations entières simultanément, grâce à 6 ALU entières. Les accès mémoire ou opérations flottantes ne viennent pas en plus, mais en remplacement des opérations entières. Regrouper plusieurs ALU sur un même port d'émission est donc à l'origine de dépendances structurelles, de nouvelles contraintes d’appariement. Dans certaines situations de '''''port contention''''', on a assez d'ALU pour exécuter N micro-opérations, mais la répartition des ALUs sur les ports l'empêche. Impossible d'émettre deux micro-opérations sur deux ALU si elles sont sur le même port d'émission. On peut réduire les cas de ''port contention'' en répartissant correctement les ALU sur les ports d'émission, mais sans jamais les supprimer complétement. Un exemple est celui du PowerPC 604. Il implémentait la quadruple émission, alors qu'il avait 5 unités de calcul. Mais deux unités partageaient un même port d'émission. Dans le détail, il avait deux ALU entières, un circuit multiplieur/diviseur, une FPU, une unité mémoire. La FPU et le multiplieur étaient sur le même port d'émission. En conséquence, le CPU pouvait émettre seulement les combinaisons INT + INT + MEM + (MUL/FLOAT). : Il est intéressant de voir l'exemple d'un processeur superscalaire parfait, sans contraintes d'appariement, à N voies. Pour cela, on part d'un processeur non-superscalaire, avec M unités de calcul. Un tel processeur peut s'implémenter avec N ports d'émission, chacun relié à M unités de calcul possibles, ce qui fait que les M unités de calcul sont dupliquées en N exemplaires. Si on prend par exemple un CPU non-superscalaire avec une ALU, une FPU, une unité mémoire et une unité de branchement, un port d'émission sera relié à une ALU, une FPU, une unité mémoire et une unité de branchement. Dupliquer les ALU entière est une première étape, mais il est aussi possible de dupliquer d'autres unités, comme l'unité mémoire ou l'unité de branchement. La rentabilité en performance dépend surtout de si cela autorise des paires d'instructions fréquentes. Le cout en circuit n'est aussi pas le même. Sur les CPU superscalaires ''in-order'', seuls les ALU et FPU sont dupliquées. ===Les CPU superscalaires mélangent les trois techniques=== Les CPU superscalaires utilisent toutes les techniques à la fois : partitionnement, duplication de l'ALU entière, duplication des autres unités fonctionnelles. Chaque processeur utilise ces trois stratégies à sa sauce, en fonction de son budget en transistor. Les quatre techniques ont cependant un cout en circuit différent, et donnent des gains de performances eux aussi forts différents. * Le '''partitionnement superscalaire''' a un cout en circuit modéré, car on n'a pas besoin de dupliquer d'ALU. * La '''duplication des ALU entières''' est la technique la plus importante : elle a un cout en circuit modéré, et donne des gains en performance impressionnants. * Le '''partage des ports d'émission''' réduit le cout en circuit du CPU, pour des pertes de performances mineures si bien fait. L'usage du partitionnement seul, sans duplication, était surtout le fait des processeurs commercialisés entre les années 1989 et 1993. De nos jours, les processeurs utilisent un mix différent d'unités, et leur partitionnement est différent. Ils tendent à s'adapter au mieux aux besoins des programmes exécutés. D'après diverses estimations, un programme informatique exécute environ 40% de son code dans des instructions arithmétiques, un autre 40% dans les accès mémoire, et un le reste pour... le reste. L'idéal est donc de pouvoir émettre autant de µops mémoire que de µops entières, à chaque cycle. Dit autrement, la largeur du processeur doit pouvoir être répartie ''moitié opération - moitié mémoire''. Bien évidemment, le processeur peut gérer d'autres combinaisons. Pour s'adapter, les processeurs dupliquent d'abord les ALU entières, suivi par l'unité mémoire, et enfin par les autres unités. Pour s’adapter au mieux, un processeur à N voies doit disposer d'au moins N/2 ALU entières, l'idéal étant d'aller au-delà. Les CPU superscalaires historiques avaient 2 ALU pour de la quadruple émission, ce qui était très limite. Les CPU superscalaires modernes mettent le paquet niveau ALU entière. La plupart des CPU x86 modernes descendent rarement sous les 2/3, la plupart remplissent presque la totalité de la largeur du CPU. Pour donner un exemple, la micro-architecture Golden Cove d'Intel est un processeur à 6 voies, avec 5 ALU entières. Ensuite, les processeurs modernes dupliquent leurs unités mémoire. Par exemple, la micro-architecture ''Golden Cove'' d'Intel dispose de trois unités mémoire : deux spécialisées dans les écritures, trois dans les lectures. La micro-architecture ''Gracemont'' utilise la quintuple émission et peut émettre 4 µops entières et 4 µops mémoire. Notez que le ratio est équilibré entre opérations entières et mémoire, avec soit égalité, soit un avantage pour les ALU entières. Une autre régularité est que la largeur totale du processeur peut être occupée par des opérations entières/mémoire. Pour le dire autrement, sur un processeur quadruple émission, il pourra émettre un mix de 4 instructions entière/mémoire, avec zéro opération flottante. Idem avec des processeurs triples ou quintuple émission, voire plus. Si le processeur émet une opération flottante, elle remplace une instruction entière ou mémoire. Prenons l'exemple des processeurs AMD K6, sortis en 1997, juste avant les AMD Athlon. Ils avaient 10 unités de calcul, 6 ports d'émission, 4 ports de décodage. Voici exactement comment étaient répartie les unités de calcul sur les ports d'émission. C'était des processeurs quadruple émission, ce qui fait que la moitié de la largeur du processeur sert pour des opérations entières, et l'autre moitié pour les accès mémoire. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 ! |- | ALU entière || ALU entière || FPU || Unité de branchement || Unité LOAD || Unité STORE |- | || Multiplieur || || || || Unité de calcul d'adresse (instruction LEA) |- | || ''Barrel shifter'' || || || || Unité pour les instructions PUSH |} Les micro-architectures K7, K8 et K10, utilisées sur les anciens CPU AMD Athlon, sont un exemple moins représentatif. Ces micro-architectures utilisent le partage des ports d'émission au maximum. C'était des micro-architectures à triple émission, avec six ports d’émission. Il y avait trois ports d'émission pour les opérations flottantes, et trois ports d'émission pour les micro-opérations entières/mémoire. Il y avait trois ALU entières et trois unités mémoire, chaque ALU partageait un port d'émission avec une unité mémoire. Elle pouvait donc émettre soit trois opérations entières, soit trois µops mémoire, soit un mix des deux. En plus des mix avec les opérations flottantes et branchements. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 ! |- | ALU entière || ALU entière || ALU entière || FADD || FMUL || FPU (autres opérations) |- | Multiplieur || || || || || |- | LOAD/STORE || LOAD/STORE || LOAD/STORE || || || |} Les CPU superscalaires larges dupliquent les multiplieurs et/ou les ''barrel shifters'', et même les unités de branchement. La raison est que quand la largeur du CPU augmente, le processeur traite des paquets d'instructions de plus en plus gros. Par exemple, pour un CPU à 10 voies, cela veut dire émettre 10 instructions en même temps. Il est alors plus fréquent d'avoir deux multiplications, deux décalages, deux accès mémoire, deux branchements. Par exemple, la moyenne est d'une multiplication tous les 5 instructions. Les CPU superscalaires à 10 voies ont donc tendance à avoir deux multiplications par paquet d'instruction, en moyenne. Avoir deux multiplieurs est alors un gain en performance clair. Par exemple, la micro-architecture ''Golden Cove'' a deux ''barrel shifters''. C'est une architecture à 6 voies. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 !! Port d'émission n°7 !! Port d'émission n°8 !! Port d'émission n°9 !! Port d'émission n°10 |- | ALU entière || ALU entière || ALU entière || ALU entière || ALU entière || LOAD || LOAD || LOAD || STORE || STORE |- | ''Barrel shifter'' || Multiplieur || Multiplieur || ''Barrel shifter'' || || || || || || |- | Branchements || Diviseur || || Branchements || || || || || || |} ==Les µops mémoire sur les processeurs superscalaires== Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. Voyons ces deux cas dans le détail. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ==Les processeurs à double émission== Dans cette section, nous allons étudier la forme la plus basique de superscalarité : les processeurs à double émission. De tels processeurs n'utilisent généralement pas l'exécution dans le désordre, mais il y a quelques exceptions. Les premiers CPU à double émission sont assez représentatifs des CPU à double émission modernes. Il faut dire que se limiter à deux voies ne permet pas de faire beaucoup de choses. En général, les processeurs à double émission ne dupliquent pas l'unité mémoire. Cela ne servirait à rien de faire deux accès mémoire simultanés sur un processeur à double émission. Et il en est de même pour la FPU et l'unité de branchement. Ils se limitent à deux techniques : dupliquer l'ALU entière, le partitionnement. Quant au partage des ports d'émission, c'est là que les ennuis commencent. L'implémentation la plus simple de la double émission utilise deux ports d'émission. Elle donne l'illusion que le processeur dispose de '''deux pipelines séparés'''. Ils partent d'un processeur non-superscalaire, et ajoutent un second pipeline, en sortie de l'unité d'émission. Le second pipeline permet l'émission parallèle d'une seconde instruction. Le problème est que les deux ports d'émission sont reliés à un grand nombre d'unités de calcul. L'unité d'émission doit donc partager des ports d'émission, et c'est là que les subtilités commencent. Il faut répartir les unités au mieux pour éviter d'avoir trop de contraintes d’appariement problématiques. Le second pipeline n'est pas identique au pipeline originel, sur plusieurs points. Premièrement, il n'a pas exactement les mêmes circuits. Le second pipeline est généralement composé d'une unité de calcul, et permet d'exécuter une seconde opération de calcul, pas plus. En conséquence, les contraintes d'appariement assez fortes. Et il n'a pas forcément la même longueur, il peut avoir des étages en moins ou en plus. Il s'agit de la forme la plus simple de processeur superscalaire et nous allons leur consacrer cette section. Une autre implémentation utilise de la double émission, mais avec 3, 4, 5, ... ports d'émission. Le processeur ne donne plus l'impression d'avoir deux pipelines séparés, mais gagne en flexibilité. Les contraintes d'appariement sont plus faibles, moins nombreuses, moins problématiques. Mais le cout en circuit n'est pas négligeable. Voici le tableau chronologique disant quel CPU utilisait quelle technique. * Les CPU en vert ajoutaient un second pipeline pour les opérations flottantes. * Les CPU en rouge ajoutaient un second pipeline pour les opérations entières. * Les CPU en jaune utilisaient un ''partitionnement complet'' entre ALU, FPU et unité mémoire. * Les CPU sans couleur utilisaient un grand nombre d'unités, avec beaucoup de ports d'émission. * Les CPU barrés utilisent la triple ou quadruple émission et seront vu dans la suite. {|class="wikitable" |- ! !! IBM !! HP PA-RISC !! SPARC !! Intel!! Alpha !! Autres |- ! 1989 | class="f_vert | RS/6000 || || || class="f_rouge | i960CA || || |- ! 1990 | || || || || || |- ! 1991 | || || || || || Motorola 88110 |- ! 1992 | class="f_vert | RSC || class="f_vert | PA 7100 || <s>SuperSPARC</s> || || class="f_jaune | 21064 || |- ! 1993 | class="f_vert | PPC 601 || || class="f_jaune | HyperSPARC || class="f_rouge | Pentium || || |- ! 1994 | <s>POWER 2</s> || PA 7200 || || || <s>21164</s> || |} ===La double émission entière-flottante=== L'exemple le plus simple utilise un second pipeline pour les opérations flottantes, séparé du pipeline originel. L'ajout d'un pipeline flottant permet d'émettre une micro-opération entière en même temps qu'une micro-opération flottante, ce qui s'appelle la '''double émission entière-flottante'''. La micro-opération entière s'exécute dans l'ALU entière, la micro-opération flottante dans la FPU. Les exemples les plus notables sont le PA-RISC 7100, le POWER 1 (RIOS .& et .9) et le PowerPC 601. L'Apollo DN10000 et l'Intel i860 utilisaient aussi cette forme de double émission, mais étaient des prototypes de CPU VLIW. [[File:Double émission entière-flottante.png|centre|vignette|upright=2|Double émission entière-flottante]] Pour bien expliquer les choses, prenons le processeur illustré ci-dessous, avec deux ''back-end'' séparés : un pour la FPU, et un autre pour le reste. Les instructions entières et mémoire passent toutes par le ''back-end'' généraliste. [[File:CPU non-superscalaire basique, avec une FPU.png|centre|vignette|upright=1.5|CPU non-superscalaire basique, avec une FPU]] Les deux ''back-end'' sont des pipelines séparés, mais il faut que l'unité d'émission soit spécialement conçue pour en profiter. Un processeur simple émission peut démarrer une seule µop par cycle : soit la µop est envoyée au ''back-end'' flottant, soit elle est envoyée au ''back-end'' général. Avec la double émission entière-flottante, le processeur peut envoyer une µop dans chaque pipeline, à chaque cycle. Vu que la FPU et son banc de registre forment déjà un pipeline séparé, les seules modifications se font au niveau du ''front-end'', unité d'émission inclue. {| |- |[[File:CPU simple émission avec une FPU.png|centre|vignette|upright=1.5|CPU simple émission avec une FPU.]] |[[File:Double émission entière flottante avec un second pipeline.png|centre|vignette|upright=1.5|CPU avec un second pipeline flottant.]] |} Notez que le terme double émission ''entière''-flottante est un peu trompeur. En réalité, la technique permet d'émettre une instruction flottante avec n'importe quelle autre instruction non-flottante, avec des restrictions pour les branchements. Il est par exemple possible d'exécuter un calcul flottant en parallèle d'un accès mémoire. Sur les CPU 32 bits, il est possible de faire les multiplications dans la FPU. Pour rappel, la FPU contient un multiplieur entier 53-54 bits, pour multiplier les mantisses de deux flottants 64 bits, qu'on peut utiliser pour faire les multiplications entières 32 bits. Cela permet d'émettre une multiplication en même temps qu'une opération entière dans l'ALU. Ainsi, le CPU pouvait émettre deux instructions : une opération entière de base, avec soit une multiplication, soit une opération flottante. En clair, une émission de type INT + (MUL /FLOAT). Par contre, on perd la possibilité d'émettre une multiplication entière avec une opération flottante, mais c'était un compromis qui en valait la peine. Le CPU PA-RISC 7100 implémentait cette optimisation. Il s'agissait d'un CPU double émission entière-flottante, qui utilisait donc un partitionnement assez simple. Le combo double émission entière-flottante + multiplication dans la FPU permettait ici de gagner en performance, pour un cout en matériel très limité. D’autant plus limité que le jeu d'instruction prévoyait le reconditionnement de la FPU. Les multiplications ne lisaient pas leurs opérandes dans les registres entiers, mais dans les registres flottants. ===La double émission entière=== Une autre possibilité ajoute un second pipeline, capable d'exécuter des opérations entières simples, appelé le pipeline entier. Le processeur supporte alors la '''double émission entière''', à savoir qu'ils peuvent émettre une opération entière en plus d'une autre instruction. L'opération entière en question est une opération simple, comme une addition, une soustraction, une opération bit à bit, une comparaison, etc. Un exemple de CPU double émission de ce type est celui du Power PC 440, un CPU destiné à l'embarqué. Vu qu'il était destiné à l'embarqué, il n'avait pas de FPU, il n'avait pas besoin de manipuler de nombres flottants. Il n'a donc que des ALU entières, un multiplieur et une unité mémoire. Il dispose de deux ports d'émission. Le premier est connecté à une ALU entière et un circuit multiplieur, le second est relié à l'unité mémoire et une seconde ALU entière. Pour résumer : une ALU entière par port, l'unité mémoire sur un port, le multiplieur sur l'autre. Une organisation simple, mais très efficace ! [[File:PowerPC 440.png|centre|vignette|upright=2|Micro-architecture du PowerPC 440.]] Le processeur précédent n'avait pas de FPU, ce qui fait qu'il n'avait pas grand chose à connecter sur ses ports d'émission. le partage des ports d'émission était assez simple. Aussi il est intéressant de voir des exemples avec une FPU. Les exemples classiques sont les processeurs Intel i960 et Pentium. L'implémentation était différente entre l'i960 et le Pentium : Le Pentium avait une seconde ALU entière, alors que le i960 rusait. Le Pentium dupliquait l'ALU entière, pour créer un second pipeline entier. Sur le Pentium originel, les contraintes d'appariement étaient assez strictes. Le Pentium avait une FPU, mais ne pouvait pas l'utiliser en double émission. Il lui était impossible d'exécuter une opération flottante en même temps qu'une opération entière (branchements inclus). Le Cyrix 6x86 était une sorte de Pentium produit par l'entreprise Cyrix, avec une micro-architecture différente. Il avait des contraintes d’appariement réduites. Par exemple, il pouvait émettre une opération flottante en parallèle d'une opération entière. Nous détaillerons ces processeurs dans le prochain chapitre, qui porte justement sur les CPU superscalaires x86. [[File:CPU simple et double émission avec un second pipeline entier.png|centre|vignette|upright=2.5|CPU simple et double émission avec un second pipeline entier.]] L'Intel i960 n'avait pas de FPU, juste une ALU entière et une unité mémoire. Il utilisait l'unité mémoire comme une seconde ALU entière limitée. Pour rappel, une unité mémoire contient une AGU, à savoir une ALU spécialisée dans les calculs d'adresse. Elle est capable de faire des additions, soustractions, et quelques décalages limités (des décalages par 2, 4, 8, pas plus). L'Intel i960 ajoutait des connexions entre AGU et banc de registre, afin que celle-ci serve de seconde unité de calcul. L'AGU est déjà reliée aux deux ports de lecture, pour lire les opérandes du calcul d'adresse, il suffit de la connecter sur le port d'écriture. Le processeur peut alors émettre deux instructions/µops entières, sous certaines conditions. [[File:Emission multiple des opérations entières, double émission.png|centre|vignette|upright=1.5|Émission multiple des opérations entières, double émission.]] le processeur alpha 21264 implémentait cette optimisation, mais c'était un processeur quadruple émission, aussi je le mentionne juste en passant. Il avait deux ALU entières et deux AGUs, les deux AGUs pouvaient faire des additions/soustractions et des opérations logiques. ===La double émission INT-FLOAT-MEM=== La '''double émission INT-FLOAT-MEM''' utilise la technique du partitionnement superscalaire, vue plus haut, avec cependant une petite subtilité. Elle utilise précisément une ALU entière, une FPU et une unité mémoire. En théorie, le partitionnement devrait donner un CPU triple émission, pas double émission. Et c'est là que la différence entre ports d'émission et de décodage devient intéressante. L'idée est d'avoir une unité d'émission avec deux ports de décodage, et 2 ports d'émission (un par unité). Les contraintes d’appariement varient alors suivant le processeur, mais la règle générale est que si deux micro-opérations tombent dans deux unités différentes, on peut les émettre en même temps. En clair, on peut émettre une opération entière avec une opération flottante, une opération entière avec un accès mémoire, une opération flottante avec un accès mémoire. Et cela rend le partitionnement bien plus performant que prévu. L'avantage sur la double émission flottante est que les combinaisons INT + MEM et FLOAT + MEM étaient autorisées. Le processeur pouvait donc émettre une opération en parallèle d'un accès mémoire, ce qui est un doublet d'instruction assez fréquent. Le gain en performance par rapport à la doublé émission entière-flottante était substantiel. Un détail est que cette forme de double émission ne peut pas s'interpréter comme un processeur à deux pipelines. Le processeur n'a pas deux ports d'émission, mais trois, et les contraintes d'appariement collent assez mal avec deux pipelines séparés. Il est possible d'interpréter le tout comme un CPU à trois pipelines, mais qui n'autorise que les paires d'instructions, mais ce serait assez tiré par les cheveux. Aussi, la notion de "plusieurs pipelines indépendants" ne marche pas pour décrire ce genre de processeurs superscalaires. Leur nombre de voies reste de deux, mais le nombre de pipelines est indéterminé. Les exemples classiques sont l'HyperSPARC, l'alpha 21064 et le PowerPC 603. Le PowerPC 603 implémentait une double émission "parfaite". C'est à dire que toutes les combinaisons possibles à deux instructions étaient possibles, que ce soit INT + FLOAT, INT + MEM, FLOAT + MEM. En plus de cette double émission "parfaite", le CPU utilisait la technique dite de ''branch folding'', qui permettait d'exécuter un branchement sans utiliser d'unité de calcul, ce qui fait qu'il était qualifié de ''CPU 2-way + Branch''. A l'opposé, le processeur alpha 21064 avait pas mal de contraintes d’appariement, à savoir que certaines combinaisons de deux instructions étaient interdites, bien que compatibles avec les unités disponibles. Une des raisons est que le banc de registre n'avait pas assez de ports de lectures, ce qui fait que certaines combinaisons avec une écriture mémoire n'étaient pas permises. Les concepteurs avaient volontairement inséré ces contraintes afin de simplifier le design du processeur. ===La double émission avec beaucoup d'unités entières=== Nous venons de voir qu'il est possible d'utiliser efficacement un surplus de ports d'émission, afin de réduire les contraintes d'appariement. Des ports d'émission qui étaient partagés sont simplement dupliqués et les ALU sont réparties sur des ports d'émission différents. Il est possible de pousser cette logique encore plus loin, en utilisant un grand nombre d'unité de calcul. L'exemple historique le plus impressionnant est celui du Motorola 88110, un CPU double émission qui possède pas moins de 10 unités ! Il contient deux ALU entières, un circuit multiplieur, un circuit diviseur, une unité de manipulation de bits, une unité mémoire, un additionneur flottant, et deux unités graphiques pour des calculs impliquant des pixels. Le multiplieur est utilisé à la fois pour les multiplications entières, flottantes et le produit de deux pixels. Idem pour le diviseur, qui sert pour les divisions entières et flottantes. Et le partitionnement est poussé au point où chaque unité a son propre port d'émission. La conséquence est que les contraintes d'appariement du Motorola 88110 sont simples : tant que deux µops atterrissent dans des unités séparées, la paire est permise. Il est par exemple possible d'émettre simultanément deux opérations entières simples, vu qu'il y a deux ALU. Ou encore, d'émettre une addition et une multiplication, une multiplication et un décalage, un décalage et une division, une addition entière et une addition flottante, etc. Par contre, impossible d'émettre simultanément deux multiplications, deux divisions, deux opérations flottantes, deux manipulations de bit, deux accès mémoire, etc. De plus, le partage du multiplieur fait qu'on ne peut pas émettre une multiplication entière avec une multiplication flottante. Idem avec les divisions, vu que le diviseur est aussi partagé. Ce sont les seules contraintes, et c'est très peu comparé aux autres processeurs double émission. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le parallélisme mémoire | prevText=Le parallélisme mémoire | next=Les processeurs superscalaires out-of-order | nextText=Les processeurs superscalaires out-of-order }} </noinclude> r0bf4p4krke6rvc67liww00o8ydzhpi 774170 774169 2026-10-04T19:28:34Z Mewtow 31375 /* L'émission parallèle des accès mémoire */ 774170 wikitext text/x-wiki Les processeurs vus auparavant ne peuvent émettre au maximum qu'une instruction par cycle d'horloge. Ils peuvent avoir plusieurs instructions qui s'exécutent en même temps, dans des unités de calcul séparées. C'est le cas dès qu'une instruction multi-cycles s'exécute. Par contre, ils ne peuvent démarrer qu'une seule instruction par cycle. 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'''. Un processeur à émission multiple charge plusieurs instructions en même temps, les décode en parallèle, puis les émet en même temps sur des unités de calculs séparées. Pour cela, 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. En général, pour un processeur à émission multiple capable d'émettre N instructions en parallèle, on parle de '''processeur à N voies''', ou encore un ''processeur à N pipelines''. [[File:Superscalarpipeline.svg|centre|vignette|upright=1.5|Pipeline RISC classique à cinq étages sur un processeur superscalaire. On voit bien que plusieurs instructions sont chargées en même temps.]] 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 qu'ils ont un jeu d'instruction spécialisé qui expose le parallélisme d'instruction directement au niveau du jeu d'instructions, là où les processeurs superscalaires restent des processeurs au jeu d'instruction normal. La différence principale entre processeur VLIW et superscalaire 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. Nous allons voir uniquement les processeurs superscalaires dans ce chapitre. Et plus précisément ceux sans exécution dans le désordre. Un processeur superscalaire peut utiliser l'exécution dans le désordre, mais ce n'est pas une obligation. Historiquement, les premiers processeurs superscalaires étaient des processeurs ''in-order'' (ce qui veut dire qu'ils n'utilisaient pas d'exécution dans le désordre). Le meilleur exemple est celui du Pentium 1 et 2 d'Intel. Ils étaient tous deux des processeurs superscalaires ''in-order''. Dans ce chapitre, nous allons voir uniquement les processeurs superscalaires ''in-order'' pour simplifier les explications. Il faut cependant garder à l'esprit que les explications qui vont suivre marchent aussi pour les processeurs superscalaires ''out of order'', sauf pour quelques exceptions. Aussi, ce chapitre servira d'introduction au chapitre suivant. ==Le nombre de voies d'un processeur superscalaire== En général, les processeurs superscalaires ''in-order'' 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 triple émission permettent eux d’émettre trois instructions à la fois, mais ils sont plus rares. A l'opposé, les CPU superscalaires les plus puissants peuvent émettre jusqu'à 8-10 instructions simultanément. En 2026, les CPU superscalaires à exécution dans le désordre peuvent monter jusqu’à 10 µops émises en même temps, ce qui est énorme ! Et cela nous amène à faire une distinction entre les processeurs superscalaires dits '''étroits''' et ceux dits '''larges'''. Dans ce wikilivre, on part du principe que les processeurs superscalaires étroits émettent jusqu'à 4-5 µops à la fois, alors que les larges peuvent en émettre plus, l'intervalle entre les deux est une zone grise. Le nombre exact n'est pas fixé dans le marbre, la distinction est volontairement vague. Nous parlerons de la '''largeur d'un CPU superscalaire''' pour parler du nombre de voies, du nombre de µops émises en même temps. Plus un CPU superscalaire est large, plus il est performant ''toutes choses égales par ailleurs''. Mais dans les faits, vous pouvez oublier la partie en italique. Élargir un CPU superscalaire demande de rajouter beaucoup de circuits. Et ce cout en circuit aura un impact sur la performance. À budget en transistors limité, il y a un compromis entre élargir un CPU et rajouter du cache ou la taille des ''schedulers''/ROB et autres. ===Les contraintes d’appariement=== Prenons un processeur double émission, capable d'émettre deux instructions simultanément. Émettre deux instructions en même temps n'est pas toujours possible. Par exemple, si deux instructions sont dépendantes, on ne peut pas les émettre en même temps. Mais oublions un instant ces histoires de dépendances de données, et partons du principe que le CPU ait affaire à deux instructions indépendantes, une paire idéale. Un CPU double émission "parfait" pourrait exécuter n'importe quelle paire d'instruction. Par exemple, il pourrait émettre deux multiplications consécutives, deux lectures consécutives, une lecture et une écriture en même temps, etc. En pratique, les processeurs qui font ça sont rare. Implémenter un CPU double émission "parfait" a un cout en circuit conséquent. Par exemple, pour émettre deux multiplications en même temps, il faudrait deux multiplieurs séparés. Idem pour exécuter deux accès mémoire simultanés : cela demande d'avoir deux unités mémoire et un cache multiport. La totalité des CPU double émission se débrouille avec un seul circuit multiplieur et une seule unité mémoire. Et cela entraine l'apparition de dépendances structurelles, qui font que certaines paires d'instructions sont impossibles. Par exemple, impossible d'émettre deux multiplications en même temps avec un seul circuit multiplieur. Mais émettre une multiplication en parallèle d'une addition est parfaitement possible. Il y a donc des paires d'instructions autorisées et des paires interdites. Pour le dire autrement, il y a des '''contraintes d'appariement''' sur les instructions à émettre en même temps. Ces contraintes d’appariement ont un impact sur les performances, mais celui-ci peut être grandement mitigé. Les programmes informatiques utilisent en pratique beaucoup plus d'opérations entières de base que de multiplication, branchements et accès mémoire. Il y a environ 5 opérations entières de base pour une multiplication entière. En pratique, il est rare de devoir exécuter deux multiplications à la fois. Idem pour les décalages et rotations, qui sont assez éloignés les uns des autres. Deux branchements consécutifs sont quant à eux plus fréquents. Deux accès mémoire consécutifs est assez rare, mais ils restent assez proches les uns des autres malgré tout. La conséquence est que certaines paires d'instructions sont privilégiées par rapport au reste. Les paires regroupant deux calculs basiques (additions, soustractions, ...) sont systématiquement supportées, car dupliquer des ALU entières est facile. Les paires regroupant un calcul et une autre instruction sont elles aussi systématiquement supportées. Par contre, les paires avec deux branchements, deux accès mémoire, deux décalages/rotations, ou deux multiplications/divisions sont fréquemment interdites. Seuls les CPU superscalaires larges peuvent les permettre, et encore : sous conditions. Le résultat est que le processeur peut se débrouiller avec une seule unité mémoire et une unité de branchement. Décrire les contraintes d’appariement demande de dire quelles combinaisons d’instructions sont autorisées ou interdites. Et pour cela, nous allons introduire les abréviations suivantes : * INT est un raccourci pour parler d'une opération entière simple, qu'une ALU entière peut exécuter. * MUL correspond à une opération entière complexe, typiquement une multiplication, éventuellement à une division. * FLOAT désigne une opération flottante, peut importe laquelle. * MEM désigne un accès mémoire, à savoir une instruction ou µop mémoire. * BRANCH désigne une instruction de branchement. Cette terminologie permettra de définir des paires d'instructions, qu'elles soient possibles ou interdites. Par exemple, l'expression INT + FLOAT désigne une paire d'instruction regroupant une instruction entière et une instruction flottante, peu importe l'ordre des deux instructions. Il en sera de même pour les triplets, quadruplets, ou plus. Par exemple, l'expression INT + INT + FLOAT + MUL désigne un quadruplet d'instructions regroupant : deux instructions entières, une instruction flottante, et une multiplication entière. J'utiliserais aussi des parenthèses pour regrouper certaines combinaisons. Par exemple, l'expression INT + (MUL /FLOAT) veut parler d'une paire d'instruction où la première est une opération entière basique, la seconde est : soit une multiplication, soit une opération flottante. ===L'historique simplifié de la superscalairité=== L'histoire des CPU superscalaires a commencé dès 1960, mais est resté un projet de recherche pendant plusieurs décennies. De nombreux designs ont été conçus, mais abandonnés avant la production, comme l'ACS-1 d'IBM. Les années 1980 ont vu l'arrivée de prototypes de processeurs VLIW, mais qui n'étaient pas superscalaires. Par exemple, l'Apollo DN10000 et l'Intel i860 pouvaient émettre deux instructions à la fois, sous conditions, mais demandaient que le compilateur fasse une bonne partie du travail. Les premiers CPU superscalaires commercialisés sont sortis en 1989. Deux CPUs superscalaires sont sortis cette année-là : le RS/6000 d'IBM et l'i960CA d'Intel. Puis, la compétition s'est aussi lancée dans la bataille. Les CPU commercialisés avant l'année 1993 étaient à double émission, à l'exception du SuperSPARC qui utilisait la triple émission. Les modèles après 1993 hésitaient entre double et quadruple émission. Dans le tableau ci-dessous, les processeurs quadruple émission sont en bleu, les triple émission sont en rouge, les autres ne sont pas en couleur. {|class="wikitable" |- ! !! IBM !! HP PA-RISC !! SPARC !! Intel!! Alpha !! Autres |- ! 1989 | RS/6000 || || || i960CA || || |- ! 1990 | || || || || || |- ! 1991 | || || || || || Motorola 88110 |- ! 1992 | RSC || PA 7100 || class="f_rouge | SuperSPARC || || 21064 || |- ! 1993 | PPC 601 || || HyperSPARC || Pentium || || |- ! 1994 | class="f_bleu | POWER 2 || PA 7200 || || || class="f_bleu | 21164 || |- ! 1995 | 603/604 || class="f_bleu | PA 8000 || class="f_bleu | UltraSPARC 1 || Microarchitecture P6 (+ concurrence AMD) || || class="f_bleu | MIPS R1000 |- ! 1996 | class="f_bleu | 620 || || || || class="f_bleu | 21264 || |- ! ... || ... || ... || ... || ... || ... || ... |} ==Le ''front-end'' des processeurs superscalaires== Un processeur superscalaire doit charger, décoder, et exécuter plusieurs instructions par cycle. Par exemple, un processeur triple émission doit charger 3 instructions lors d'un premier cycle, puis les décoder au cycle suivant, et les exécuter au cycle suivant. Il travaille donc pas paquets de 3 instructions, dans cet exemple. Si c'est un processeur superscalaire ''in-order'', cela donne l'impression que le pipeline dispose de plusieurs '''pipelines séparés'''. Intuitivement, charger, décoder et exécuter plusieurs instructions à la fois demande de dupliquer des circuits. Par exemple, il faut dupliquer les décodeurs pour décoder plusieurs instructions. Les unités de calcul sont aussi dupliquées, sauf sur certains processeurs qui arrivent à ruser, mais laissons cela de côté pour plus tard. Mais d'autres circuits sont simplement modifiés, comme l'unité de chargement ou l'unité d'émission. Voyons ce qu'il en est dans le détail. {|class="wikitable" |- ! rowspan="2" | Processeur sans émission multiple | rowspan="2" | Chargement | rowspan="2" | Décodage | rowspan="2" | Émission | Exécution / ALU | rowspan="2" | ''Commit''/ROB |- | Exécution / ALU |- ! rowspan="4" | Processeur superscalaire | rowspan="4" | Chargement | rowspan="2" | Décodage | rowspan="4" | Émission | Exécution / ALU | rowspan="4" | ''Commit''/ROB |- | Exécution / ALU |- | rowspan="2" | Décodage | Exécution / ALU |- | Exécution / ALU |} Nous allons d'abord voir ce qu'il en est pour le ''front-end'', à savoir la partie du CPU qui s'occupe de charger et décoder les instructions. Elle regroupe l'unité de chargement, les décodeurs, l'unité de prédiction de branchement, la file de µops, la file d'instruction, et divers caches. En soi, les changements portent surtout sur l'unité de chargement et les décodeurs. ===Le chargement de plusieurs instructions consécutives=== Un processeur superscalaire charge plusieurs instructions en même temps, ''en tenant compte de la présence de branchements''. La partie en italique est importante et elle a une conséquence très forte : la manière de faire est fortement différente entre un CPU superscalaire étroit et large. Un CPU superscalaire étroit fait au plus simple : il charge un bloc de 2, 3, 4 instructions consécutives en mémoire RAM. Il faut alors doubler/tripler/quadrupler la taille du bus connecté au cache d'instruction. Par exemple, pour charger quatre instructions de 32 bits chacune, il suffit de lire un bloc de 128 bits dans le cache d'instruction. Un bloc de 8, 16, 32 octets est donc lu depuis le cache et est ensuite découpé en instructions, envoyées chacun à un décodeur. Charger plusieurs instructions consécutives ne marche bien qu'en absence de branchements. La présence de branchements casse cette dynamique, et il faut gérer leur présence. * Sans prédiction de branchement, toutes les instructions d'un bloc sont décodées et exécutées en même temps, même s'il y a un branchement dans le tas. * Avec prédiction de branchement, les instructions situées après le branchement ne sont pas décodées ni exécutées. L'unité de chargement coupe le bloc chargé au niveau du premier branchement pris, remplit les vides avec des NOP, avant d'envoyer le tout à l'unité de décodage. Les instructions à l'adresse de destination seront chargées au cycle suivant, en chargeant un nouveau bloc. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un autre défaut est que sur le CPU charge un bloc de 128 bits à la fois, les instructions doivent être alignées sur 128 bits. Et on peut généraliser à n'importe quelle taille de bloc. Si les instructions ne sont pas alignées, il faut gérer la situation. Il arrive qu'une instruction soit à cheval sur deux blocs, avec un morceau dans le premier bloc, un autre dans le second. Dans ce cas, l'instruction est chargée en deux fois, et des circuits doivent gérer la situation pour recoller les morceaux. Sur les CPU avec des instructions de taille fixe, le problème ne survient que rarement. Mais il est fréquent avec des instructions de taille variable. Un autre défaut est lié aux branchements. Imaginez qu'un branchement s'exécute et branche vers une instruction de destination. Rien n'indique que l'instruction de destination sera au tout début d'un bloc. Si ça se trouve, elle sera en troisième position d'un bloc de 4 instructions. Une solution simple charge le bloc en respectant l'alignement, et les deux premières instructions du bloc ne sont pas à exécuter. Il y a donc une petite perte de performance. Certains CPU, comme l'IBM RS/6000, ont la machinerie charger un bloc de N instructions même s'il n'est pas aligné. La machinerie en question se trouve dans le cache d'instruction et est tout sauf simple à expliquer, ce qui fait que je me permets de la passer sous silence. Un détail, cependant : elle ne permet pas de charger des blocs qui sont à cheval sur deux lignes de cache. ===Le décodage parallèle des instructions=== Pour le décodage des instructions, plusieurs instructions sont décodées en même temps. On parle alors de '''décodage parallèle'''. Pour ce faire, un processeur superscalaire contient plusieurs décodeurs, chacun pouvant décoder une instruction en parallèle des autres. Prenons par exemple un processeur superscalaire à 4 voies : le décodage se fera dans quatre décodeurs séparés, qui fonctionneront en parallèle. Cependant, cette implémentation ne marche bien que pour les processeurs RISC, les processeurs CISC devant faire avec un microcode gourmand en circuits. Les processeurs CISC utilisent des décodeurs hybrides, avec un microcode qui complémente un décodeur câblé. Dupliquer le microcode aurait un cout en transistors trop important, ce qui fait que seuls les décodeurs câblés sont dupliqués. Les CPU CISC superscalaires disposent donc de plusieurs décodeurs simples, capables de décoder les instructions les plus courantes, avec un seul microcode. La conséquence est qu'il n'est pas possible de décoder deux instructions microcodées en même temps. Par contre, il reste possible de décoder plusieurs instructions non-microcodées ou une instruction microcodée couplée à une instruction non-microcodée. Vu qu'il est rare que deux instructions microcodées se suivent dans un programme, le cout en performance est extrêmement mineur. Il faut noter qu'une instruction correspond à une ou plusieurs µops. Il est donc possible de charger 5 instructions et de se retrouver avec 8 µops en sortie des décodeurs. Et c'est la source d'une distinction très importante. Plus haut, j'ai parlé de processeurs superscalaires à N voies. La terminologie peut avoir deux interprétations. La première est que le processeur charge/décode N instructions à la fois. Une seconde est qu'il est capable d'exécuter/émettre N µops en même temps. Les deux ne sont pas équivalentes, vu qu'un processeur superscalaire peut, par exemple, charger/décoder N instructions et émettre/exécuter N+2 µops. La distinction est particulièrement importante sur les processeurs superscalaires larges. Certains d'entre eux sont capables de charger 5 instructions et d'en émettre le double ! Un exemple classique était les processeurs Athlon d'AMD, qui pouvaient décoder deux instructions par cycle, mais émettre 4 µops par cycle. Ce n'était pas un problème, car beaucoup d'instructions, même basiques, étaient décodées en 2 µops. ===L'émission parallèle des instructions=== La superscalarité modifie en profondeur l'unité d'émission, notamment au niveau de son interface. Avant, elle recevait une micro-opération sur son entrée, et fournissait la micro-opération émise sur une sortie. Et cela vaut aussi bien pour une unité d'émission simple, un ''scoreboard'', un ''scheduler'' 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. Dans ce qui suit, nous ne parlerons pas d'entrée et de sortie, mais de '''ports de décodage''' et de '''ports d'émission'''. Les ports de décodage reçoivent des micro-opérations provenant de l'unité de décodage, les ports d'émissions sont pour les micro-opérations émises. Les ports d'émission injectent les micro-opérations émises dans le pipeline, elles sont propagées au banc de registre, aux ALUs, etc. Un processeur superscalaire a forcément plusieurs ports d'émission (et de décodage). [[File:Ports de décodage et d'émission.png|centre|vignette|upright=2|Ports de décodage et d'émission]] Un détail important est que l'unité d'émission peut avoir plus de ports d'émission que de ports de décodage. Et n'allez pas croire qu'un surplus de ports d'émission est du gâchis. Sur les processeurs avec exécution dans le désordre, ce surplus peut être utilisé pour émettre une µops sur chaque port d'émission par cycle d'horloge, ce qui donne un gain de performance appréciable. Ceci dit, les processeurs sans exécution dans le désordre utilisent aussi des ports d'émission en surplus. La raison sera expliquée dans quelques sections, mais nous pouvons dire que cela simplifie la conception du processeur. Si l'unité d'émission a un surplus de ports d'émission, on doit alors distinguer sa ''largeur de décodage'' et sa ''largeur d'émission''. La '''largeur de décodage''' est le nombre de µops fournies par l'unité de décodage par cycle d'horloge, la '''largeur d'émission''' est le nombre maximal de µops émises par cycle d'horloge. La largeur d'émission est égale ou supérieure à la largeur de décodage, tout dépend du processeur. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] Si l'interface de l'unité d'émission change, l'intérieur est tout autant modifié. Sur un CPU non-superscalaire, l'unité d'émission reçoit une µop "candidate à l'émission", et détecte les dépendances entre avec les µops en vol, en cours d'exécution dans le pipeline. Et c'est toujours le cas sur les processeurs superscalaires : l'unité d'émission détecte les dépendances entre les µops candidates et les µops en vol. Mais avec la siperscalarité, elle doit en plus tester les dépendances entre les µops candidates. Si l'unité d'émission est un ''scoreboard'', il doit détecter les dépendances entre instructions émises simultanément et les sérialiser si besoin. Avec un ''scheduler'', il n'y a pour ainsi dire rien à faire, vu que les dépendances sont éliminées par le renommage de registre et que les signaux de réveil gèrent les dépendances RAW. C'est la raison pour laquelle les processeurs superscalaires modernes n'utilisent pas de ''scoreboard''. ==Le ''back-end'' des processeurs superscalaires== Posons-nous cette question : de combien d'unités un processeur superscalaire a besoin ? Les unités en question ne sont pas nécessairement des unités de calcul, l'unité mémoire et l'unité de branchement comptent aussi. La réponse dépend du nombre de voies. Si le ''front-end'' charge et décode un paquet de N instructions, exécuter ces N instructions simultanément demandera là aussi au moins N unités de calcul. Notez le terme : ''au moins'', car c'est là tout l'enjeu de cette section. La réponse dépend aussi des contraintes d'appariement. Pour un CPU superscalaire "parfait", sans contraintes d’appariement, toutes les unités doivent être dupliquées, y compris l'unité mémoire. Un processeur à double émission parfaite devrait dupliquer l'ALU entière, le ''barrel shifter'', le circuit multiplieur, la FPU, l'unité mémoire, et l'unité de branchement, ce qui ferait passer de 6 unités à 12. Le cout en circuit est énorme, même pour de la double émission ! Et pour un CPU à N pipelines, le cout en circuit encore plus prohibitif ! En pratique, les processeurs superscalaires qui font ça sont très rares. La majeure partie des processeurs superscalaires utilisent d'autres techniques : le partitionnement, la duplication partielle des ALUs, la duplication des autres unités. ===Le partitionnement des unités fonctionnelles=== Les CPU superscalaires les plus simples ne dupliquent pas leurs unités de calcul, ils se contentent d'exploiter les unités existantes. Et pour expliquer comment, nous allons partir de l'exemple d'un processeur non-superscalaire contient une ALU entière, une FPU, une unité mémoire et une unité de branchement. Les quatre unités peuvent fonctionner en parallèle, à savoir qu'une unité peut exécuter une instruction à elle toute seule, sans interagir avec les autres. Cela suffit pour créer un processeur quadruple émission, qui peut émettre en même temps : une opération entière, une opération flottante, un branchement, un accès mémoire. Le principe marche aussi sur un processeur avec moins de 4 unités, par exemple sans FPU et/ou sans unité de branchement, ou avec au contraire des unités en plus. L'idée générale est d’émettre une µops séparée dans chaque unité fonctionnelle. Utiliser ainsi les unités existantes, pour rendre un CPU superscalaire, s'appelle le '''partitionnement superscalaire'''. Il consiste à attribuer un port d'émission à chaque unité existante. Le terme n'est pas beaucoup utilisé en dehors de quelques vieux articles académiques. Le partitionnement est fortement dépendant du jeu d'instruction, car il demande de bien séparer instructions entières, flottantes, branchements et instructions mémoire. Et autant c'est bien le cas sur les CPU RISC, autant les CPU CISC ne sont pas dans ce cas. Les CPU CISC ont des instructions ''load-op'', qui effectuent une instruction arithmétique et une µop mémoire. Et ces instructions sont très compliquées à implémenter avec le partitionnement. Et au-delà de ça, les CPU CISC ont beaucoup d'instructions qui sont décodées en plusieurs µops, à exécuter dans un ordre bien précis. On peut pas émettre les µops en question simultanément. Le partitionnement a un cout en circuit très limité, pour un gain en performance lui aussi limité. Pour reprendre le CPU de l'exemple précédent, il est rare que l'on ait à exécuter ces quatre instructions en même temps. En pratique, les combinaisons fréquentes sont les combinaisons calcul + accès mémoire, calcul + branchement, calcul entier + calcul flottant. Utiliser une quadruple émission de ce type n'est donc pas une bonne idée, alors que la double émission parait plus adaptée. Avant de poursuivre, précisons que la FPU regroupe un additionneur flottant et un multiplieur flottant. Et ce fait peut être exploité pour approfondir le partitionnement. La '''double émission FADD/FMUL''' permet d'émettre une addition flottante en même temps qu'une multiplication flottante. Elle a été utilisée sur les processeurs x86, Alpha et SPARC. Les autres circuits de calcul flottant sont souvent regroupés avec l'additionneur. La division est parfois e même port que le multiplieur flottant, parfois sur celui de l'additionneur (quand elle est microcodée et exécutée en enchainant les soustractions). ===La duplication des ALU entières=== Le partitionnement est une technique élégante, simple, facile à implémenter et au cout en transistors modeste. Mais il faut avouer que cela ne donne pas des CPU superscalaires très efficaces. Le partitionnement permet d'émettre des paires, triplets, quadruplets de micro-opérations qui sont rares en pratique. Et les paires, triplets, quadruplets d'instructions courants ne sont pas supportés. Les contraintes d’appariement sont trop strictes. Les combinaisons les plus fréquentes en pratique sont des paires/triplets d'opérations entières, souvent des paires/triplets d'additions ou d'opérations entières simples. Il est donc primordial d'émettre plusieurs opérations entières simultanément. Le fait d'émettre et d'exécuter plusieurs opérations entières en même temps s'appelle l''''émission entière multiple'''. Pour exécuter la seconde/troisième opération entière, il faut dupliquer les ALU entières (ou réutiliser l'AGU comme ALU entière comme le fait l'Intel i960). Le processeur doit avoir deux ALU entières pour de la double émission entière, trois pour de triple émission entière, etc. De plus, il faut ajouter des ports au banc de registre, pour lire deux fois plus d'opérandes et décrire deux résultats. Et enfin, il faut rajouter un port d'émission pour pouvoir émettre une micro-opération entière en plus. [[File:Processeur non-superscalaire basique 03.png|centre|vignette|upright=2|Processeur non-superscalaire basique]] La tendance est de multiplier les ALU entières, mais pas le circuit multiplieur. Le cout en transistors est alors grandement réduit, au prix de l'apparition de dépendances structurelles. Le CPU peut émettre deux additions en même temps, ou une addition et une multiplication, mais pas deux multiplications. Il en est de même avec le ''barrel shifter'', qui n'est pas dupliqué. Il est donc possible d'émettre un décalage avec une opération de base, mais pas deux décalages en parallèle. Mais ces contraintes d’appariement ne sont pas un problème, car il est rare de devoir émettre deux multiplications/décalages en même temps. La plupart des programmes sont majoritairement remplis d'additions, avec des multiplications assez rares et des décalages qui le sont encore plus. La moyenne est proche d'une multiplication pour 4/5 additions. Vu que des multiplications consécutives sont très rares, dupliquer le multiplieur est presque inutile. Disposer de plusieurs circuits multiplieurs ou de plusieurs ''barrel shifters'' serait donc un cout en circuits qui ne servirait que rarement et n'en vaut pas la chandelle. Il faut noter que sur les CPU 32 bits, les multiplications peuvent être exécutées dans la FPU ou dans un multiplieur séparé, tout dépend de si le CPU exécute les multiplications dans la FPU ou pas. {|class="wikitable" |- ! Voie d'émission n°1 !! Voie d'émission n°2 !! Voie d'émission n°3 |- | ALU entière || ALU entière || FPU |} Reste à connecter les unités de calcul à l'unité d'émission (indirectement, car il y a des étages de pipeline entre les deux). Une implémentation basique utilisée un port d'émission par unité. Prenons l'exemple d'un CPU superscalaire avec 2 ALU entières, un multiplieur entier, un ''barrel shifter'' une FPU, et une unité mémoire. Vu qu'il y a 6 unités, cela fait donc 6 ports d'émission. C'est beaucoup, l'unité d'émission est très complexe. Suffisamment complexe pour se demander s'ils n'existerait pas des optimisations qui permettraient d'en réduire le nombre sans trop réduire les performances, ni le nombre d'ALU. Et cela nous amène à la section suivante. ===Le partage des ports d'émission=== La présence d'un circuit multiplieur et d'un ''barrel shifter'' pose des problèmes pour le partitionnement. Il est possible d'avoir un port d'émission dédié au multiplieur, et un autre au ''barrel shifter'', qui sont séparés de ceux pour l'ALU entière. Il est alors possible d'émettre une opération entière, une multiplication et un décalage, simultanément. Nous parlerons de '''partitionnement entier''' pour désigner cette possibilité. [[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]] Le partitionnement entier est cependant très rare, car le gain en performance serait trop faible au regard des couts en circuit. Quelques processeurs historiques l'utilisaient, mais ce n'est plus le cas de nos jours. La raison est que les multiplications et les décalages sont assez rares dans le code, ce qui fait que le port d'émission associés sont sous-utilisés. Et un port d'émission a un cout en circuit assez important. Et c'est sans compter qu'ajouter des ports sur le banc de registres juste pour un multiplieur sous-utilisé n'en vaut pas la peine. L'idéal serait de trouver un moyen de réduire le nombre de ports d'émissions, sans réduire le nombre d'unités de calcul. [[File:Emission multiple des opérations entières, implémentation naive.png|vignette|Émission multiple des opérations entières, implémentation naive]] La solution consiste à '''partager les ports d'émission''', à savoir connecter un port d'émission à plusieurs unités de calcul. L'exemple classique est justement lié aux circuits multiplieur et ''barrel shifters''. L'idée est que le multiplieur et le ''barrel shifter'' sont mis sur le même port d'émission qu'une ALU entière. Prenons par exemple le processeur à double émission entière illustré ci-contre. Un port d'émission est relié à une ALU entière seule, et l'autre port d'émission alimente la seconde ALU entière et le multiplieur. La conséquence est que le processeur peut émettre deux opérations entières, dont une multiplication. Si on avait utilisé un port d'émission dédié au multiplieur, il aurait été possible d'émettre deux opérations entières et une multiplication. On perd donc une voie, mais on gagne en flexibilité et on économise des circuits. Notons que ce partage signifie aussi que le multiplieur partage les ports sur le banc de registre, ce qui fait une double économie. [[File:Emission multiple des opérations entières, implémentation courante.png|centre|vignette|upright=1.5|Émission multiple des opérations entières, implémentation courante]] En général, le ''barrel shifter'' et le multiplieur sont placés sur deux voies d'émission séparés, ce qui permet d'émettre un décalage et une multiplication en même temps. Les mettre sur le même port ne le permettrait pas. {|class="wikitable" |- ! Voie d'émission n°1 !! Voie d'émission n°2 !! Voie d'émission n°3 |- | * ALU entière * Multiplieur | * ALU entière * Barrel shifter | FPU |- | * Additions, soustractions, opérations bit à bit, etc. * Multiplication | * Additions, soustractions, opérations bit à bit, etc. * Décalages et rotations | Opération flottante |} Terminons concernant les multiplieurs avec une dernière subtilité. Pour rappel, les processeurs modernes intègrent en réalité deux multiplieurs différents. Le résultat d'une multiplication est très large et prend deux registres. Par exemple, une multiplication de deux opérandes 64 bits donne un résultat sur 128 bits, ce qui ne tient pas dans un registre, mais en prend deux. L'instruction de multiplication classique ne calcule que les 64 bits de poids faible. mais elle est complétée par d'autres instructions de multiplication qui peuvent faire le calcul complet. Pour résoudre le problème, les processeurs modernes intègrent deux instructions de multiplications : une qui calcule les bits de poids faible du résultat, une autre qui calcule les bits de poids fort. Et ces deux instructions sont associées à deux multiplieurs séparés, aux circuits différents. Le multiplieur qui calcule les bits de poids faible du résultat est appelé le ''low multiplier'', l'autre est appelé le ''high multiplier''. Le second multiplieur est souvent placé sur un second port d'émission, différent du premier multiplieur, partagé avec une ALU. L’avantage de mettre les deux multiplieurs sur deux ports d'émission différents est qu'une multiplication complète peut être réalisée en utilisant les deux multiplieurs en parallèle. Les deux sous-multiplications sont émises en même temps, exécutées en même temps. pas besoin d'exécuter l'une puis la seconde, d'où un gain performance appréciable. Nous venons de voir comment partager un port d'émission entre une ALU et un multiplieur. Mais au-delà de cet exemple, le partage des ports d'émission peut appliquer à n'importe quelle unité. Prenons l'exemple d'un CPU superscalaire avec 4 ALU entières, une FPU, et une unité mémoire. Vu qu'il y a 6 unités, cela fait donc 6 ports d'émission. Cependant, il est rare qu'un bloc de 6 instructions contienne exactement 4 opérations entières, une opération flottante, et un accès mémoire. Beaucoup de ports d'émission seraient sous-utilisés. La solution est de partager des ports d'émission, pour passer à la quadruple émission. Le CPU peut alors répartir 4 instructions sur les ports d'émission adéquats, à chaque cycle. Le CPU a donc 4 ports de décodage et 6 ports d'émission. Une implémentation classique permet n'importe quelle combinaison de 4 instructions compatibles avec les 6 unités. Par exemple, il devient possible d'émettre simultanément 4 additions, la combinaison 2 * INT + FLOAT + MEM, 3 * INT + MEM, et bien d'autres. L'exemple relie une ALU entière, la FPU et l'unité mémoire sur le même port. Le CPU a alors quatre ports d'émission, qui ne sont pas "identiques". Trois d'entre eux sont reliés à une ALU entière chacun, rien d'autre. Le quatrième est lui relié à une ALU entière, la FPU et l'unité mémoire. On économise alors deux ports d'émission, mais cela entraine l'apparition de contraintes d’appariement. Le CPU peut émettre 3 opérations entières + n'importe quelle autre opération. Mais les autres combinaisons sont interdites. Le CPU peut émettre 6 opérations entières simultanément, grâce à 6 ALU entières. Les accès mémoire ou opérations flottantes ne viennent pas en plus, mais en remplacement des opérations entières. Regrouper plusieurs ALU sur un même port d'émission est donc à l'origine de dépendances structurelles, de nouvelles contraintes d’appariement. Dans certaines situations de '''''port contention''''', on a assez d'ALU pour exécuter N micro-opérations, mais la répartition des ALUs sur les ports l'empêche. Impossible d'émettre deux micro-opérations sur deux ALU si elles sont sur le même port d'émission. On peut réduire les cas de ''port contention'' en répartissant correctement les ALU sur les ports d'émission, mais sans jamais les supprimer complétement. Un exemple est celui du PowerPC 604. Il implémentait la quadruple émission, alors qu'il avait 5 unités de calcul. Mais deux unités partageaient un même port d'émission. Dans le détail, il avait deux ALU entières, un circuit multiplieur/diviseur, une FPU, une unité mémoire. La FPU et le multiplieur étaient sur le même port d'émission. En conséquence, le CPU pouvait émettre seulement les combinaisons INT + INT + MEM + (MUL/FLOAT). : Il est intéressant de voir l'exemple d'un processeur superscalaire parfait, sans contraintes d'appariement, à N voies. Pour cela, on part d'un processeur non-superscalaire, avec M unités de calcul. Un tel processeur peut s'implémenter avec N ports d'émission, chacun relié à M unités de calcul possibles, ce qui fait que les M unités de calcul sont dupliquées en N exemplaires. Si on prend par exemple un CPU non-superscalaire avec une ALU, une FPU, une unité mémoire et une unité de branchement, un port d'émission sera relié à une ALU, une FPU, une unité mémoire et une unité de branchement. Dupliquer les ALU entière est une première étape, mais il est aussi possible de dupliquer d'autres unités, comme l'unité mémoire ou l'unité de branchement. La rentabilité en performance dépend surtout de si cela autorise des paires d'instructions fréquentes. Le cout en circuit n'est aussi pas le même. Sur les CPU superscalaires ''in-order'', seuls les ALU et FPU sont dupliquées. ===Les CPU superscalaires mélangent les trois techniques=== Les CPU superscalaires utilisent toutes les techniques à la fois : partitionnement, duplication de l'ALU entière, duplication des autres unités fonctionnelles. Chaque processeur utilise ces trois stratégies à sa sauce, en fonction de son budget en transistor. Les quatre techniques ont cependant un cout en circuit différent, et donnent des gains de performances eux aussi forts différents. * Le '''partitionnement superscalaire''' a un cout en circuit modéré, car on n'a pas besoin de dupliquer d'ALU. * La '''duplication des ALU entières''' est la technique la plus importante : elle a un cout en circuit modéré, et donne des gains en performance impressionnants. * Le '''partage des ports d'émission''' réduit le cout en circuit du CPU, pour des pertes de performances mineures si bien fait. L'usage du partitionnement seul, sans duplication, était surtout le fait des processeurs commercialisés entre les années 1989 et 1993. De nos jours, les processeurs utilisent un mix différent d'unités, et leur partitionnement est différent. Ils tendent à s'adapter au mieux aux besoins des programmes exécutés. D'après diverses estimations, un programme informatique exécute environ 40% de son code dans des instructions arithmétiques, un autre 40% dans les accès mémoire, et un le reste pour... le reste. L'idéal est donc de pouvoir émettre autant de µops mémoire que de µops entières, à chaque cycle. Dit autrement, la largeur du processeur doit pouvoir être répartie ''moitié opération - moitié mémoire''. Bien évidemment, le processeur peut gérer d'autres combinaisons. Pour s'adapter, les processeurs dupliquent d'abord les ALU entières, suivi par l'unité mémoire, et enfin par les autres unités. Pour s’adapter au mieux, un processeur à N voies doit disposer d'au moins N/2 ALU entières, l'idéal étant d'aller au-delà. Les CPU superscalaires historiques avaient 2 ALU pour de la quadruple émission, ce qui était très limite. Les CPU superscalaires modernes mettent le paquet niveau ALU entière. La plupart des CPU x86 modernes descendent rarement sous les 2/3, la plupart remplissent presque la totalité de la largeur du CPU. Pour donner un exemple, la micro-architecture Golden Cove d'Intel est un processeur à 6 voies, avec 5 ALU entières. Ensuite, les processeurs modernes dupliquent leurs unités mémoire. Par exemple, la micro-architecture ''Golden Cove'' d'Intel dispose de trois unités mémoire : deux spécialisées dans les écritures, trois dans les lectures. La micro-architecture ''Gracemont'' utilise la quintuple émission et peut émettre 4 µops entières et 4 µops mémoire. Notez que le ratio est équilibré entre opérations entières et mémoire, avec soit égalité, soit un avantage pour les ALU entières. Une autre régularité est que la largeur totale du processeur peut être occupée par des opérations entières/mémoire. Pour le dire autrement, sur un processeur quadruple émission, il pourra émettre un mix de 4 instructions entière/mémoire, avec zéro opération flottante. Idem avec des processeurs triples ou quintuple émission, voire plus. Si le processeur émet une opération flottante, elle remplace une instruction entière ou mémoire. Prenons l'exemple des processeurs AMD K6, sortis en 1997, juste avant les AMD Athlon. Ils avaient 10 unités de calcul, 6 ports d'émission, 4 ports de décodage. Voici exactement comment étaient répartie les unités de calcul sur les ports d'émission. C'était des processeurs quadruple émission, ce qui fait que la moitié de la largeur du processeur sert pour des opérations entières, et l'autre moitié pour les accès mémoire. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 ! |- | ALU entière || ALU entière || FPU || Unité de branchement || Unité LOAD || Unité STORE |- | || Multiplieur || || || || Unité de calcul d'adresse (instruction LEA) |- | || ''Barrel shifter'' || || || || Unité pour les instructions PUSH |} Les micro-architectures K7, K8 et K10, utilisées sur les anciens CPU AMD Athlon, sont un exemple moins représentatif. Ces micro-architectures utilisent le partage des ports d'émission au maximum. C'était des micro-architectures à triple émission, avec six ports d’émission. Il y avait trois ports d'émission pour les opérations flottantes, et trois ports d'émission pour les micro-opérations entières/mémoire. Il y avait trois ALU entières et trois unités mémoire, chaque ALU partageait un port d'émission avec une unité mémoire. Elle pouvait donc émettre soit trois opérations entières, soit trois µops mémoire, soit un mix des deux. En plus des mix avec les opérations flottantes et branchements. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 ! |- | ALU entière || ALU entière || ALU entière || FADD || FMUL || FPU (autres opérations) |- | Multiplieur || || || || || |- | LOAD/STORE || LOAD/STORE || LOAD/STORE || || || |} Les CPU superscalaires larges dupliquent les multiplieurs et/ou les ''barrel shifters'', et même les unités de branchement. La raison est que quand la largeur du CPU augmente, le processeur traite des paquets d'instructions de plus en plus gros. Par exemple, pour un CPU à 10 voies, cela veut dire émettre 10 instructions en même temps. Il est alors plus fréquent d'avoir deux multiplications, deux décalages, deux accès mémoire, deux branchements. Par exemple, la moyenne est d'une multiplication tous les 5 instructions. Les CPU superscalaires à 10 voies ont donc tendance à avoir deux multiplications par paquet d'instruction, en moyenne. Avoir deux multiplieurs est alors un gain en performance clair. Par exemple, la micro-architecture ''Golden Cove'' a deux ''barrel shifters''. C'est une architecture à 6 voies. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 !! Port d'émission n°7 !! Port d'émission n°8 !! Port d'émission n°9 !! Port d'émission n°10 |- | ALU entière || ALU entière || ALU entière || ALU entière || ALU entière || LOAD || LOAD || LOAD || STORE || STORE |- | ''Barrel shifter'' || Multiplieur || Multiplieur || ''Barrel shifter'' || || || || || || |- | Branchements || Diviseur || || Branchements || || || || || || |} ==Les µops mémoire sur les processeurs superscalaires== Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. Voyons ces deux cas dans le détail. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== Une autre tendance est de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ==Les processeurs à double émission== Dans cette section, nous allons étudier la forme la plus basique de superscalarité : les processeurs à double émission. De tels processeurs n'utilisent généralement pas l'exécution dans le désordre, mais il y a quelques exceptions. Les premiers CPU à double émission sont assez représentatifs des CPU à double émission modernes. Il faut dire que se limiter à deux voies ne permet pas de faire beaucoup de choses. En général, les processeurs à double émission ne dupliquent pas l'unité mémoire. Cela ne servirait à rien de faire deux accès mémoire simultanés sur un processeur à double émission. Et il en est de même pour la FPU et l'unité de branchement. Ils se limitent à deux techniques : dupliquer l'ALU entière, le partitionnement. Quant au partage des ports d'émission, c'est là que les ennuis commencent. L'implémentation la plus simple de la double émission utilise deux ports d'émission. Elle donne l'illusion que le processeur dispose de '''deux pipelines séparés'''. Ils partent d'un processeur non-superscalaire, et ajoutent un second pipeline, en sortie de l'unité d'émission. Le second pipeline permet l'émission parallèle d'une seconde instruction. Le problème est que les deux ports d'émission sont reliés à un grand nombre d'unités de calcul. L'unité d'émission doit donc partager des ports d'émission, et c'est là que les subtilités commencent. Il faut répartir les unités au mieux pour éviter d'avoir trop de contraintes d’appariement problématiques. Le second pipeline n'est pas identique au pipeline originel, sur plusieurs points. Premièrement, il n'a pas exactement les mêmes circuits. Le second pipeline est généralement composé d'une unité de calcul, et permet d'exécuter une seconde opération de calcul, pas plus. En conséquence, les contraintes d'appariement assez fortes. Et il n'a pas forcément la même longueur, il peut avoir des étages en moins ou en plus. Il s'agit de la forme la plus simple de processeur superscalaire et nous allons leur consacrer cette section. Une autre implémentation utilise de la double émission, mais avec 3, 4, 5, ... ports d'émission. Le processeur ne donne plus l'impression d'avoir deux pipelines séparés, mais gagne en flexibilité. Les contraintes d'appariement sont plus faibles, moins nombreuses, moins problématiques. Mais le cout en circuit n'est pas négligeable. Voici le tableau chronologique disant quel CPU utilisait quelle technique. * Les CPU en vert ajoutaient un second pipeline pour les opérations flottantes. * Les CPU en rouge ajoutaient un second pipeline pour les opérations entières. * Les CPU en jaune utilisaient un ''partitionnement complet'' entre ALU, FPU et unité mémoire. * Les CPU sans couleur utilisaient un grand nombre d'unités, avec beaucoup de ports d'émission. * Les CPU barrés utilisent la triple ou quadruple émission et seront vu dans la suite. {|class="wikitable" |- ! !! IBM !! HP PA-RISC !! SPARC !! Intel!! Alpha !! Autres |- ! 1989 | class="f_vert | RS/6000 || || || class="f_rouge | i960CA || || |- ! 1990 | || || || || || |- ! 1991 | || || || || || Motorola 88110 |- ! 1992 | class="f_vert | RSC || class="f_vert | PA 7100 || <s>SuperSPARC</s> || || class="f_jaune | 21064 || |- ! 1993 | class="f_vert | PPC 601 || || class="f_jaune | HyperSPARC || class="f_rouge | Pentium || || |- ! 1994 | <s>POWER 2</s> || PA 7200 || || || <s>21164</s> || |} ===La double émission entière-flottante=== L'exemple le plus simple utilise un second pipeline pour les opérations flottantes, séparé du pipeline originel. L'ajout d'un pipeline flottant permet d'émettre une micro-opération entière en même temps qu'une micro-opération flottante, ce qui s'appelle la '''double émission entière-flottante'''. La micro-opération entière s'exécute dans l'ALU entière, la micro-opération flottante dans la FPU. Les exemples les plus notables sont le PA-RISC 7100, le POWER 1 (RIOS .& et .9) et le PowerPC 601. L'Apollo DN10000 et l'Intel i860 utilisaient aussi cette forme de double émission, mais étaient des prototypes de CPU VLIW. [[File:Double émission entière-flottante.png|centre|vignette|upright=2|Double émission entière-flottante]] Pour bien expliquer les choses, prenons le processeur illustré ci-dessous, avec deux ''back-end'' séparés : un pour la FPU, et un autre pour le reste. Les instructions entières et mémoire passent toutes par le ''back-end'' généraliste. [[File:CPU non-superscalaire basique, avec une FPU.png|centre|vignette|upright=1.5|CPU non-superscalaire basique, avec une FPU]] Les deux ''back-end'' sont des pipelines séparés, mais il faut que l'unité d'émission soit spécialement conçue pour en profiter. Un processeur simple émission peut démarrer une seule µop par cycle : soit la µop est envoyée au ''back-end'' flottant, soit elle est envoyée au ''back-end'' général. Avec la double émission entière-flottante, le processeur peut envoyer une µop dans chaque pipeline, à chaque cycle. Vu que la FPU et son banc de registre forment déjà un pipeline séparé, les seules modifications se font au niveau du ''front-end'', unité d'émission inclue. {| |- |[[File:CPU simple émission avec une FPU.png|centre|vignette|upright=1.5|CPU simple émission avec une FPU.]] |[[File:Double émission entière flottante avec un second pipeline.png|centre|vignette|upright=1.5|CPU avec un second pipeline flottant.]] |} Notez que le terme double émission ''entière''-flottante est un peu trompeur. En réalité, la technique permet d'émettre une instruction flottante avec n'importe quelle autre instruction non-flottante, avec des restrictions pour les branchements. Il est par exemple possible d'exécuter un calcul flottant en parallèle d'un accès mémoire. Sur les CPU 32 bits, il est possible de faire les multiplications dans la FPU. Pour rappel, la FPU contient un multiplieur entier 53-54 bits, pour multiplier les mantisses de deux flottants 64 bits, qu'on peut utiliser pour faire les multiplications entières 32 bits. Cela permet d'émettre une multiplication en même temps qu'une opération entière dans l'ALU. Ainsi, le CPU pouvait émettre deux instructions : une opération entière de base, avec soit une multiplication, soit une opération flottante. En clair, une émission de type INT + (MUL /FLOAT). Par contre, on perd la possibilité d'émettre une multiplication entière avec une opération flottante, mais c'était un compromis qui en valait la peine. Le CPU PA-RISC 7100 implémentait cette optimisation. Il s'agissait d'un CPU double émission entière-flottante, qui utilisait donc un partitionnement assez simple. Le combo double émission entière-flottante + multiplication dans la FPU permettait ici de gagner en performance, pour un cout en matériel très limité. D’autant plus limité que le jeu d'instruction prévoyait le reconditionnement de la FPU. Les multiplications ne lisaient pas leurs opérandes dans les registres entiers, mais dans les registres flottants. ===La double émission entière=== Une autre possibilité ajoute un second pipeline, capable d'exécuter des opérations entières simples, appelé le pipeline entier. Le processeur supporte alors la '''double émission entière''', à savoir qu'ils peuvent émettre une opération entière en plus d'une autre instruction. L'opération entière en question est une opération simple, comme une addition, une soustraction, une opération bit à bit, une comparaison, etc. Un exemple de CPU double émission de ce type est celui du Power PC 440, un CPU destiné à l'embarqué. Vu qu'il était destiné à l'embarqué, il n'avait pas de FPU, il n'avait pas besoin de manipuler de nombres flottants. Il n'a donc que des ALU entières, un multiplieur et une unité mémoire. Il dispose de deux ports d'émission. Le premier est connecté à une ALU entière et un circuit multiplieur, le second est relié à l'unité mémoire et une seconde ALU entière. Pour résumer : une ALU entière par port, l'unité mémoire sur un port, le multiplieur sur l'autre. Une organisation simple, mais très efficace ! [[File:PowerPC 440.png|centre|vignette|upright=2|Micro-architecture du PowerPC 440.]] Le processeur précédent n'avait pas de FPU, ce qui fait qu'il n'avait pas grand chose à connecter sur ses ports d'émission. le partage des ports d'émission était assez simple. Aussi il est intéressant de voir des exemples avec une FPU. Les exemples classiques sont les processeurs Intel i960 et Pentium. L'implémentation était différente entre l'i960 et le Pentium : Le Pentium avait une seconde ALU entière, alors que le i960 rusait. Le Pentium dupliquait l'ALU entière, pour créer un second pipeline entier. Sur le Pentium originel, les contraintes d'appariement étaient assez strictes. Le Pentium avait une FPU, mais ne pouvait pas l'utiliser en double émission. Il lui était impossible d'exécuter une opération flottante en même temps qu'une opération entière (branchements inclus). Le Cyrix 6x86 était une sorte de Pentium produit par l'entreprise Cyrix, avec une micro-architecture différente. Il avait des contraintes d’appariement réduites. Par exemple, il pouvait émettre une opération flottante en parallèle d'une opération entière. Nous détaillerons ces processeurs dans le prochain chapitre, qui porte justement sur les CPU superscalaires x86. [[File:CPU simple et double émission avec un second pipeline entier.png|centre|vignette|upright=2.5|CPU simple et double émission avec un second pipeline entier.]] L'Intel i960 n'avait pas de FPU, juste une ALU entière et une unité mémoire. Il utilisait l'unité mémoire comme une seconde ALU entière limitée. Pour rappel, une unité mémoire contient une AGU, à savoir une ALU spécialisée dans les calculs d'adresse. Elle est capable de faire des additions, soustractions, et quelques décalages limités (des décalages par 2, 4, 8, pas plus). L'Intel i960 ajoutait des connexions entre AGU et banc de registre, afin que celle-ci serve de seconde unité de calcul. L'AGU est déjà reliée aux deux ports de lecture, pour lire les opérandes du calcul d'adresse, il suffit de la connecter sur le port d'écriture. Le processeur peut alors émettre deux instructions/µops entières, sous certaines conditions. [[File:Emission multiple des opérations entières, double émission.png|centre|vignette|upright=1.5|Émission multiple des opérations entières, double émission.]] le processeur alpha 21264 implémentait cette optimisation, mais c'était un processeur quadruple émission, aussi je le mentionne juste en passant. Il avait deux ALU entières et deux AGUs, les deux AGUs pouvaient faire des additions/soustractions et des opérations logiques. ===La double émission INT-FLOAT-MEM=== La '''double émission INT-FLOAT-MEM''' utilise la technique du partitionnement superscalaire, vue plus haut, avec cependant une petite subtilité. Elle utilise précisément une ALU entière, une FPU et une unité mémoire. En théorie, le partitionnement devrait donner un CPU triple émission, pas double émission. Et c'est là que la différence entre ports d'émission et de décodage devient intéressante. L'idée est d'avoir une unité d'émission avec deux ports de décodage, et 2 ports d'émission (un par unité). Les contraintes d’appariement varient alors suivant le processeur, mais la règle générale est que si deux micro-opérations tombent dans deux unités différentes, on peut les émettre en même temps. En clair, on peut émettre une opération entière avec une opération flottante, une opération entière avec un accès mémoire, une opération flottante avec un accès mémoire. Et cela rend le partitionnement bien plus performant que prévu. L'avantage sur la double émission flottante est que les combinaisons INT + MEM et FLOAT + MEM étaient autorisées. Le processeur pouvait donc émettre une opération en parallèle d'un accès mémoire, ce qui est un doublet d'instruction assez fréquent. Le gain en performance par rapport à la doublé émission entière-flottante était substantiel. Un détail est que cette forme de double émission ne peut pas s'interpréter comme un processeur à deux pipelines. Le processeur n'a pas deux ports d'émission, mais trois, et les contraintes d'appariement collent assez mal avec deux pipelines séparés. Il est possible d'interpréter le tout comme un CPU à trois pipelines, mais qui n'autorise que les paires d'instructions, mais ce serait assez tiré par les cheveux. Aussi, la notion de "plusieurs pipelines indépendants" ne marche pas pour décrire ce genre de processeurs superscalaires. Leur nombre de voies reste de deux, mais le nombre de pipelines est indéterminé. Les exemples classiques sont l'HyperSPARC, l'alpha 21064 et le PowerPC 603. Le PowerPC 603 implémentait une double émission "parfaite". C'est à dire que toutes les combinaisons possibles à deux instructions étaient possibles, que ce soit INT + FLOAT, INT + MEM, FLOAT + MEM. En plus de cette double émission "parfaite", le CPU utilisait la technique dite de ''branch folding'', qui permettait d'exécuter un branchement sans utiliser d'unité de calcul, ce qui fait qu'il était qualifié de ''CPU 2-way + Branch''. A l'opposé, le processeur alpha 21064 avait pas mal de contraintes d’appariement, à savoir que certaines combinaisons de deux instructions étaient interdites, bien que compatibles avec les unités disponibles. Une des raisons est que le banc de registre n'avait pas assez de ports de lectures, ce qui fait que certaines combinaisons avec une écriture mémoire n'étaient pas permises. Les concepteurs avaient volontairement inséré ces contraintes afin de simplifier le design du processeur. ===La double émission avec beaucoup d'unités entières=== Nous venons de voir qu'il est possible d'utiliser efficacement un surplus de ports d'émission, afin de réduire les contraintes d'appariement. Des ports d'émission qui étaient partagés sont simplement dupliqués et les ALU sont réparties sur des ports d'émission différents. Il est possible de pousser cette logique encore plus loin, en utilisant un grand nombre d'unité de calcul. L'exemple historique le plus impressionnant est celui du Motorola 88110, un CPU double émission qui possède pas moins de 10 unités ! Il contient deux ALU entières, un circuit multiplieur, un circuit diviseur, une unité de manipulation de bits, une unité mémoire, un additionneur flottant, et deux unités graphiques pour des calculs impliquant des pixels. Le multiplieur est utilisé à la fois pour les multiplications entières, flottantes et le produit de deux pixels. Idem pour le diviseur, qui sert pour les divisions entières et flottantes. Et le partitionnement est poussé au point où chaque unité a son propre port d'émission. La conséquence est que les contraintes d'appariement du Motorola 88110 sont simples : tant que deux µops atterrissent dans des unités séparées, la paire est permise. Il est par exemple possible d'émettre simultanément deux opérations entières simples, vu qu'il y a deux ALU. Ou encore, d'émettre une addition et une multiplication, une multiplication et un décalage, un décalage et une division, une addition entière et une addition flottante, etc. Par contre, impossible d'émettre simultanément deux multiplications, deux divisions, deux opérations flottantes, deux manipulations de bit, deux accès mémoire, etc. De plus, le partage du multiplieur fait qu'on ne peut pas émettre une multiplication entière avec une multiplication flottante. Idem avec les divisions, vu que le diviseur est aussi partagé. Ce sont les seules contraintes, et c'est très peu comparé aux autres processeurs double émission. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le parallélisme mémoire | prevText=Le parallélisme mémoire | next=Les processeurs superscalaires out-of-order | nextText=Les processeurs superscalaires out-of-order }} </noinclude> 6ufoh2v9qtfnkbbm0e44ea4kjhjn0ae 774171 774170 2026-10-04T19:34:55Z Mewtow 31375 /* La double émission avec beaucoup d'unités entières */ 774171 wikitext text/x-wiki Les processeurs vus auparavant ne peuvent émettre au maximum qu'une instruction par cycle d'horloge. Ils peuvent avoir plusieurs instructions qui s'exécutent en même temps, dans des unités de calcul séparées. C'est le cas dès qu'une instruction multi-cycles s'exécute. Par contre, ils ne peuvent démarrer qu'une seule instruction par cycle. 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'''. Un processeur à émission multiple charge plusieurs instructions en même temps, les décode en parallèle, puis les émet en même temps sur des unités de calculs séparées. Pour cela, 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. En général, pour un processeur à émission multiple capable d'émettre N instructions en parallèle, on parle de '''processeur à N voies''', ou encore un ''processeur à N pipelines''. [[File:Superscalarpipeline.svg|centre|vignette|upright=1.5|Pipeline RISC classique à cinq étages sur un processeur superscalaire. On voit bien que plusieurs instructions sont chargées en même temps.]] 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 qu'ils ont un jeu d'instruction spécialisé qui expose le parallélisme d'instruction directement au niveau du jeu d'instructions, là où les processeurs superscalaires restent des processeurs au jeu d'instruction normal. La différence principale entre processeur VLIW et superscalaire 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. Nous allons voir uniquement les processeurs superscalaires dans ce chapitre. Et plus précisément ceux sans exécution dans le désordre. Un processeur superscalaire peut utiliser l'exécution dans le désordre, mais ce n'est pas une obligation. Historiquement, les premiers processeurs superscalaires étaient des processeurs ''in-order'' (ce qui veut dire qu'ils n'utilisaient pas d'exécution dans le désordre). Le meilleur exemple est celui du Pentium 1 et 2 d'Intel. Ils étaient tous deux des processeurs superscalaires ''in-order''. Dans ce chapitre, nous allons voir uniquement les processeurs superscalaires ''in-order'' pour simplifier les explications. Il faut cependant garder à l'esprit que les explications qui vont suivre marchent aussi pour les processeurs superscalaires ''out of order'', sauf pour quelques exceptions. Aussi, ce chapitre servira d'introduction au chapitre suivant. ==Le nombre de voies d'un processeur superscalaire== En général, les processeurs superscalaires ''in-order'' 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 triple émission permettent eux d’émettre trois instructions à la fois, mais ils sont plus rares. A l'opposé, les CPU superscalaires les plus puissants peuvent émettre jusqu'à 8-10 instructions simultanément. En 2026, les CPU superscalaires à exécution dans le désordre peuvent monter jusqu’à 10 µops émises en même temps, ce qui est énorme ! Et cela nous amène à faire une distinction entre les processeurs superscalaires dits '''étroits''' et ceux dits '''larges'''. Dans ce wikilivre, on part du principe que les processeurs superscalaires étroits émettent jusqu'à 4-5 µops à la fois, alors que les larges peuvent en émettre plus, l'intervalle entre les deux est une zone grise. Le nombre exact n'est pas fixé dans le marbre, la distinction est volontairement vague. Nous parlerons de la '''largeur d'un CPU superscalaire''' pour parler du nombre de voies, du nombre de µops émises en même temps. Plus un CPU superscalaire est large, plus il est performant ''toutes choses égales par ailleurs''. Mais dans les faits, vous pouvez oublier la partie en italique. Élargir un CPU superscalaire demande de rajouter beaucoup de circuits. Et ce cout en circuit aura un impact sur la performance. À budget en transistors limité, il y a un compromis entre élargir un CPU et rajouter du cache ou la taille des ''schedulers''/ROB et autres. ===Les contraintes d’appariement=== Prenons un processeur double émission, capable d'émettre deux instructions simultanément. Émettre deux instructions en même temps n'est pas toujours possible. Par exemple, si deux instructions sont dépendantes, on ne peut pas les émettre en même temps. Mais oublions un instant ces histoires de dépendances de données, et partons du principe que le CPU ait affaire à deux instructions indépendantes, une paire idéale. Un CPU double émission "parfait" pourrait exécuter n'importe quelle paire d'instruction. Par exemple, il pourrait émettre deux multiplications consécutives, deux lectures consécutives, une lecture et une écriture en même temps, etc. En pratique, les processeurs qui font ça sont rare. Implémenter un CPU double émission "parfait" a un cout en circuit conséquent. Par exemple, pour émettre deux multiplications en même temps, il faudrait deux multiplieurs séparés. Idem pour exécuter deux accès mémoire simultanés : cela demande d'avoir deux unités mémoire et un cache multiport. La totalité des CPU double émission se débrouille avec un seul circuit multiplieur et une seule unité mémoire. Et cela entraine l'apparition de dépendances structurelles, qui font que certaines paires d'instructions sont impossibles. Par exemple, impossible d'émettre deux multiplications en même temps avec un seul circuit multiplieur. Mais émettre une multiplication en parallèle d'une addition est parfaitement possible. Il y a donc des paires d'instructions autorisées et des paires interdites. Pour le dire autrement, il y a des '''contraintes d'appariement''' sur les instructions à émettre en même temps. Ces contraintes d’appariement ont un impact sur les performances, mais celui-ci peut être grandement mitigé. Les programmes informatiques utilisent en pratique beaucoup plus d'opérations entières de base que de multiplication, branchements et accès mémoire. Il y a environ 5 opérations entières de base pour une multiplication entière. En pratique, il est rare de devoir exécuter deux multiplications à la fois. Idem pour les décalages et rotations, qui sont assez éloignés les uns des autres. Deux branchements consécutifs sont quant à eux plus fréquents. Deux accès mémoire consécutifs est assez rare, mais ils restent assez proches les uns des autres malgré tout. La conséquence est que certaines paires d'instructions sont privilégiées par rapport au reste. Les paires regroupant deux calculs basiques (additions, soustractions, ...) sont systématiquement supportées, car dupliquer des ALU entières est facile. Les paires regroupant un calcul et une autre instruction sont elles aussi systématiquement supportées. Par contre, les paires avec deux branchements, deux accès mémoire, deux décalages/rotations, ou deux multiplications/divisions sont fréquemment interdites. Seuls les CPU superscalaires larges peuvent les permettre, et encore : sous conditions. Le résultat est que le processeur peut se débrouiller avec une seule unité mémoire et une unité de branchement. Décrire les contraintes d’appariement demande de dire quelles combinaisons d’instructions sont autorisées ou interdites. Et pour cela, nous allons introduire les abréviations suivantes : * INT est un raccourci pour parler d'une opération entière simple, qu'une ALU entière peut exécuter. * MUL correspond à une opération entière complexe, typiquement une multiplication, éventuellement à une division. * FLOAT désigne une opération flottante, peut importe laquelle. * MEM désigne un accès mémoire, à savoir une instruction ou µop mémoire. * BRANCH désigne une instruction de branchement. Cette terminologie permettra de définir des paires d'instructions, qu'elles soient possibles ou interdites. Par exemple, l'expression INT + FLOAT désigne une paire d'instruction regroupant une instruction entière et une instruction flottante, peu importe l'ordre des deux instructions. Il en sera de même pour les triplets, quadruplets, ou plus. Par exemple, l'expression INT + INT + FLOAT + MUL désigne un quadruplet d'instructions regroupant : deux instructions entières, une instruction flottante, et une multiplication entière. J'utiliserais aussi des parenthèses pour regrouper certaines combinaisons. Par exemple, l'expression INT + (MUL /FLOAT) veut parler d'une paire d'instruction où la première est une opération entière basique, la seconde est : soit une multiplication, soit une opération flottante. ===L'historique simplifié de la superscalairité=== L'histoire des CPU superscalaires a commencé dès 1960, mais est resté un projet de recherche pendant plusieurs décennies. De nombreux designs ont été conçus, mais abandonnés avant la production, comme l'ACS-1 d'IBM. Les années 1980 ont vu l'arrivée de prototypes de processeurs VLIW, mais qui n'étaient pas superscalaires. Par exemple, l'Apollo DN10000 et l'Intel i860 pouvaient émettre deux instructions à la fois, sous conditions, mais demandaient que le compilateur fasse une bonne partie du travail. Les premiers CPU superscalaires commercialisés sont sortis en 1989. Deux CPUs superscalaires sont sortis cette année-là : le RS/6000 d'IBM et l'i960CA d'Intel. Puis, la compétition s'est aussi lancée dans la bataille. Les CPU commercialisés avant l'année 1993 étaient à double émission, à l'exception du SuperSPARC qui utilisait la triple émission. Les modèles après 1993 hésitaient entre double et quadruple émission. Dans le tableau ci-dessous, les processeurs quadruple émission sont en bleu, les triple émission sont en rouge, les autres ne sont pas en couleur. {|class="wikitable" |- ! !! IBM !! HP PA-RISC !! SPARC !! Intel!! Alpha !! Autres |- ! 1989 | RS/6000 || || || i960CA || || |- ! 1990 | || || || || || |- ! 1991 | || || || || || Motorola 88110 |- ! 1992 | RSC || PA 7100 || class="f_rouge | SuperSPARC || || 21064 || |- ! 1993 | PPC 601 || || HyperSPARC || Pentium || || |- ! 1994 | class="f_bleu | POWER 2 || PA 7200 || || || class="f_bleu | 21164 || |- ! 1995 | 603/604 || class="f_bleu | PA 8000 || class="f_bleu | UltraSPARC 1 || Microarchitecture P6 (+ concurrence AMD) || || class="f_bleu | MIPS R1000 |- ! 1996 | class="f_bleu | 620 || || || || class="f_bleu | 21264 || |- ! ... || ... || ... || ... || ... || ... || ... |} ==Le ''front-end'' des processeurs superscalaires== Un processeur superscalaire doit charger, décoder, et exécuter plusieurs instructions par cycle. Par exemple, un processeur triple émission doit charger 3 instructions lors d'un premier cycle, puis les décoder au cycle suivant, et les exécuter au cycle suivant. Il travaille donc pas paquets de 3 instructions, dans cet exemple. Si c'est un processeur superscalaire ''in-order'', cela donne l'impression que le pipeline dispose de plusieurs '''pipelines séparés'''. Intuitivement, charger, décoder et exécuter plusieurs instructions à la fois demande de dupliquer des circuits. Par exemple, il faut dupliquer les décodeurs pour décoder plusieurs instructions. Les unités de calcul sont aussi dupliquées, sauf sur certains processeurs qui arrivent à ruser, mais laissons cela de côté pour plus tard. Mais d'autres circuits sont simplement modifiés, comme l'unité de chargement ou l'unité d'émission. Voyons ce qu'il en est dans le détail. {|class="wikitable" |- ! rowspan="2" | Processeur sans émission multiple | rowspan="2" | Chargement | rowspan="2" | Décodage | rowspan="2" | Émission | Exécution / ALU | rowspan="2" | ''Commit''/ROB |- | Exécution / ALU |- ! rowspan="4" | Processeur superscalaire | rowspan="4" | Chargement | rowspan="2" | Décodage | rowspan="4" | Émission | Exécution / ALU | rowspan="4" | ''Commit''/ROB |- | Exécution / ALU |- | rowspan="2" | Décodage | Exécution / ALU |- | Exécution / ALU |} Nous allons d'abord voir ce qu'il en est pour le ''front-end'', à savoir la partie du CPU qui s'occupe de charger et décoder les instructions. Elle regroupe l'unité de chargement, les décodeurs, l'unité de prédiction de branchement, la file de µops, la file d'instruction, et divers caches. En soi, les changements portent surtout sur l'unité de chargement et les décodeurs. ===Le chargement de plusieurs instructions consécutives=== Un processeur superscalaire charge plusieurs instructions en même temps, ''en tenant compte de la présence de branchements''. La partie en italique est importante et elle a une conséquence très forte : la manière de faire est fortement différente entre un CPU superscalaire étroit et large. Un CPU superscalaire étroit fait au plus simple : il charge un bloc de 2, 3, 4 instructions consécutives en mémoire RAM. Il faut alors doubler/tripler/quadrupler la taille du bus connecté au cache d'instruction. Par exemple, pour charger quatre instructions de 32 bits chacune, il suffit de lire un bloc de 128 bits dans le cache d'instruction. Un bloc de 8, 16, 32 octets est donc lu depuis le cache et est ensuite découpé en instructions, envoyées chacun à un décodeur. Charger plusieurs instructions consécutives ne marche bien qu'en absence de branchements. La présence de branchements casse cette dynamique, et il faut gérer leur présence. * Sans prédiction de branchement, toutes les instructions d'un bloc sont décodées et exécutées en même temps, même s'il y a un branchement dans le tas. * Avec prédiction de branchement, les instructions situées après le branchement ne sont pas décodées ni exécutées. L'unité de chargement coupe le bloc chargé au niveau du premier branchement pris, remplit les vides avec des NOP, avant d'envoyer le tout à l'unité de décodage. Les instructions à l'adresse de destination seront chargées au cycle suivant, en chargeant un nouveau bloc. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un autre défaut est que sur le CPU charge un bloc de 128 bits à la fois, les instructions doivent être alignées sur 128 bits. Et on peut généraliser à n'importe quelle taille de bloc. Si les instructions ne sont pas alignées, il faut gérer la situation. Il arrive qu'une instruction soit à cheval sur deux blocs, avec un morceau dans le premier bloc, un autre dans le second. Dans ce cas, l'instruction est chargée en deux fois, et des circuits doivent gérer la situation pour recoller les morceaux. Sur les CPU avec des instructions de taille fixe, le problème ne survient que rarement. Mais il est fréquent avec des instructions de taille variable. Un autre défaut est lié aux branchements. Imaginez qu'un branchement s'exécute et branche vers une instruction de destination. Rien n'indique que l'instruction de destination sera au tout début d'un bloc. Si ça se trouve, elle sera en troisième position d'un bloc de 4 instructions. Une solution simple charge le bloc en respectant l'alignement, et les deux premières instructions du bloc ne sont pas à exécuter. Il y a donc une petite perte de performance. Certains CPU, comme l'IBM RS/6000, ont la machinerie charger un bloc de N instructions même s'il n'est pas aligné. La machinerie en question se trouve dans le cache d'instruction et est tout sauf simple à expliquer, ce qui fait que je me permets de la passer sous silence. Un détail, cependant : elle ne permet pas de charger des blocs qui sont à cheval sur deux lignes de cache. ===Le décodage parallèle des instructions=== Pour le décodage des instructions, plusieurs instructions sont décodées en même temps. On parle alors de '''décodage parallèle'''. Pour ce faire, un processeur superscalaire contient plusieurs décodeurs, chacun pouvant décoder une instruction en parallèle des autres. Prenons par exemple un processeur superscalaire à 4 voies : le décodage se fera dans quatre décodeurs séparés, qui fonctionneront en parallèle. Cependant, cette implémentation ne marche bien que pour les processeurs RISC, les processeurs CISC devant faire avec un microcode gourmand en circuits. Les processeurs CISC utilisent des décodeurs hybrides, avec un microcode qui complémente un décodeur câblé. Dupliquer le microcode aurait un cout en transistors trop important, ce qui fait que seuls les décodeurs câblés sont dupliqués. Les CPU CISC superscalaires disposent donc de plusieurs décodeurs simples, capables de décoder les instructions les plus courantes, avec un seul microcode. La conséquence est qu'il n'est pas possible de décoder deux instructions microcodées en même temps. Par contre, il reste possible de décoder plusieurs instructions non-microcodées ou une instruction microcodée couplée à une instruction non-microcodée. Vu qu'il est rare que deux instructions microcodées se suivent dans un programme, le cout en performance est extrêmement mineur. Il faut noter qu'une instruction correspond à une ou plusieurs µops. Il est donc possible de charger 5 instructions et de se retrouver avec 8 µops en sortie des décodeurs. Et c'est la source d'une distinction très importante. Plus haut, j'ai parlé de processeurs superscalaires à N voies. La terminologie peut avoir deux interprétations. La première est que le processeur charge/décode N instructions à la fois. Une seconde est qu'il est capable d'exécuter/émettre N µops en même temps. Les deux ne sont pas équivalentes, vu qu'un processeur superscalaire peut, par exemple, charger/décoder N instructions et émettre/exécuter N+2 µops. La distinction est particulièrement importante sur les processeurs superscalaires larges. Certains d'entre eux sont capables de charger 5 instructions et d'en émettre le double ! Un exemple classique était les processeurs Athlon d'AMD, qui pouvaient décoder deux instructions par cycle, mais émettre 4 µops par cycle. Ce n'était pas un problème, car beaucoup d'instructions, même basiques, étaient décodées en 2 µops. ===L'émission parallèle des instructions=== La superscalarité modifie en profondeur l'unité d'émission, notamment au niveau de son interface. Avant, elle recevait une micro-opération sur son entrée, et fournissait la micro-opération émise sur une sortie. Et cela vaut aussi bien pour une unité d'émission simple, un ''scoreboard'', un ''scheduler'' 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. Dans ce qui suit, nous ne parlerons pas d'entrée et de sortie, mais de '''ports de décodage''' et de '''ports d'émission'''. Les ports de décodage reçoivent des micro-opérations provenant de l'unité de décodage, les ports d'émissions sont pour les micro-opérations émises. Les ports d'émission injectent les micro-opérations émises dans le pipeline, elles sont propagées au banc de registre, aux ALUs, etc. Un processeur superscalaire a forcément plusieurs ports d'émission (et de décodage). [[File:Ports de décodage et d'émission.png|centre|vignette|upright=2|Ports de décodage et d'émission]] Un détail important est que l'unité d'émission peut avoir plus de ports d'émission que de ports de décodage. Et n'allez pas croire qu'un surplus de ports d'émission est du gâchis. Sur les processeurs avec exécution dans le désordre, ce surplus peut être utilisé pour émettre une µops sur chaque port d'émission par cycle d'horloge, ce qui donne un gain de performance appréciable. Ceci dit, les processeurs sans exécution dans le désordre utilisent aussi des ports d'émission en surplus. La raison sera expliquée dans quelques sections, mais nous pouvons dire que cela simplifie la conception du processeur. Si l'unité d'émission a un surplus de ports d'émission, on doit alors distinguer sa ''largeur de décodage'' et sa ''largeur d'émission''. La '''largeur de décodage''' est le nombre de µops fournies par l'unité de décodage par cycle d'horloge, la '''largeur d'émission''' est le nombre maximal de µops émises par cycle d'horloge. La largeur d'émission est égale ou supérieure à la largeur de décodage, tout dépend du processeur. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] Si l'interface de l'unité d'émission change, l'intérieur est tout autant modifié. Sur un CPU non-superscalaire, l'unité d'émission reçoit une µop "candidate à l'émission", et détecte les dépendances entre avec les µops en vol, en cours d'exécution dans le pipeline. Et c'est toujours le cas sur les processeurs superscalaires : l'unité d'émission détecte les dépendances entre les µops candidates et les µops en vol. Mais avec la siperscalarité, elle doit en plus tester les dépendances entre les µops candidates. Si l'unité d'émission est un ''scoreboard'', il doit détecter les dépendances entre instructions émises simultanément et les sérialiser si besoin. Avec un ''scheduler'', il n'y a pour ainsi dire rien à faire, vu que les dépendances sont éliminées par le renommage de registre et que les signaux de réveil gèrent les dépendances RAW. C'est la raison pour laquelle les processeurs superscalaires modernes n'utilisent pas de ''scoreboard''. ==Le ''back-end'' des processeurs superscalaires== Posons-nous cette question : de combien d'unités un processeur superscalaire a besoin ? Les unités en question ne sont pas nécessairement des unités de calcul, l'unité mémoire et l'unité de branchement comptent aussi. La réponse dépend du nombre de voies. Si le ''front-end'' charge et décode un paquet de N instructions, exécuter ces N instructions simultanément demandera là aussi au moins N unités de calcul. Notez le terme : ''au moins'', car c'est là tout l'enjeu de cette section. La réponse dépend aussi des contraintes d'appariement. Pour un CPU superscalaire "parfait", sans contraintes d’appariement, toutes les unités doivent être dupliquées, y compris l'unité mémoire. Un processeur à double émission parfaite devrait dupliquer l'ALU entière, le ''barrel shifter'', le circuit multiplieur, la FPU, l'unité mémoire, et l'unité de branchement, ce qui ferait passer de 6 unités à 12. Le cout en circuit est énorme, même pour de la double émission ! Et pour un CPU à N pipelines, le cout en circuit encore plus prohibitif ! En pratique, les processeurs superscalaires qui font ça sont très rares. La majeure partie des processeurs superscalaires utilisent d'autres techniques : le partitionnement, la duplication partielle des ALUs, la duplication des autres unités. ===Le partitionnement des unités fonctionnelles=== Les CPU superscalaires les plus simples ne dupliquent pas leurs unités de calcul, ils se contentent d'exploiter les unités existantes. Et pour expliquer comment, nous allons partir de l'exemple d'un processeur non-superscalaire contient une ALU entière, une FPU, une unité mémoire et une unité de branchement. Les quatre unités peuvent fonctionner en parallèle, à savoir qu'une unité peut exécuter une instruction à elle toute seule, sans interagir avec les autres. Cela suffit pour créer un processeur quadruple émission, qui peut émettre en même temps : une opération entière, une opération flottante, un branchement, un accès mémoire. Le principe marche aussi sur un processeur avec moins de 4 unités, par exemple sans FPU et/ou sans unité de branchement, ou avec au contraire des unités en plus. L'idée générale est d’émettre une µops séparée dans chaque unité fonctionnelle. Utiliser ainsi les unités existantes, pour rendre un CPU superscalaire, s'appelle le '''partitionnement superscalaire'''. Il consiste à attribuer un port d'émission à chaque unité existante. Le terme n'est pas beaucoup utilisé en dehors de quelques vieux articles académiques. Le partitionnement est fortement dépendant du jeu d'instruction, car il demande de bien séparer instructions entières, flottantes, branchements et instructions mémoire. Et autant c'est bien le cas sur les CPU RISC, autant les CPU CISC ne sont pas dans ce cas. Les CPU CISC ont des instructions ''load-op'', qui effectuent une instruction arithmétique et une µop mémoire. Et ces instructions sont très compliquées à implémenter avec le partitionnement. Et au-delà de ça, les CPU CISC ont beaucoup d'instructions qui sont décodées en plusieurs µops, à exécuter dans un ordre bien précis. On peut pas émettre les µops en question simultanément. Le partitionnement a un cout en circuit très limité, pour un gain en performance lui aussi limité. Pour reprendre le CPU de l'exemple précédent, il est rare que l'on ait à exécuter ces quatre instructions en même temps. En pratique, les combinaisons fréquentes sont les combinaisons calcul + accès mémoire, calcul + branchement, calcul entier + calcul flottant. Utiliser une quadruple émission de ce type n'est donc pas une bonne idée, alors que la double émission parait plus adaptée. Avant de poursuivre, précisons que la FPU regroupe un additionneur flottant et un multiplieur flottant. Et ce fait peut être exploité pour approfondir le partitionnement. La '''double émission FADD/FMUL''' permet d'émettre une addition flottante en même temps qu'une multiplication flottante. Elle a été utilisée sur les processeurs x86, Alpha et SPARC. Les autres circuits de calcul flottant sont souvent regroupés avec l'additionneur. La division est parfois e même port que le multiplieur flottant, parfois sur celui de l'additionneur (quand elle est microcodée et exécutée en enchainant les soustractions). ===La duplication des ALU entières=== Le partitionnement est une technique élégante, simple, facile à implémenter et au cout en transistors modeste. Mais il faut avouer que cela ne donne pas des CPU superscalaires très efficaces. Le partitionnement permet d'émettre des paires, triplets, quadruplets de micro-opérations qui sont rares en pratique. Et les paires, triplets, quadruplets d'instructions courants ne sont pas supportés. Les contraintes d’appariement sont trop strictes. Les combinaisons les plus fréquentes en pratique sont des paires/triplets d'opérations entières, souvent des paires/triplets d'additions ou d'opérations entières simples. Il est donc primordial d'émettre plusieurs opérations entières simultanément. Le fait d'émettre et d'exécuter plusieurs opérations entières en même temps s'appelle l''''émission entière multiple'''. Pour exécuter la seconde/troisième opération entière, il faut dupliquer les ALU entières (ou réutiliser l'AGU comme ALU entière comme le fait l'Intel i960). Le processeur doit avoir deux ALU entières pour de la double émission entière, trois pour de triple émission entière, etc. De plus, il faut ajouter des ports au banc de registre, pour lire deux fois plus d'opérandes et décrire deux résultats. Et enfin, il faut rajouter un port d'émission pour pouvoir émettre une micro-opération entière en plus. [[File:Processeur non-superscalaire basique 03.png|centre|vignette|upright=2|Processeur non-superscalaire basique]] La tendance est de multiplier les ALU entières, mais pas le circuit multiplieur. Le cout en transistors est alors grandement réduit, au prix de l'apparition de dépendances structurelles. Le CPU peut émettre deux additions en même temps, ou une addition et une multiplication, mais pas deux multiplications. Il en est de même avec le ''barrel shifter'', qui n'est pas dupliqué. Il est donc possible d'émettre un décalage avec une opération de base, mais pas deux décalages en parallèle. Mais ces contraintes d’appariement ne sont pas un problème, car il est rare de devoir émettre deux multiplications/décalages en même temps. La plupart des programmes sont majoritairement remplis d'additions, avec des multiplications assez rares et des décalages qui le sont encore plus. La moyenne est proche d'une multiplication pour 4/5 additions. Vu que des multiplications consécutives sont très rares, dupliquer le multiplieur est presque inutile. Disposer de plusieurs circuits multiplieurs ou de plusieurs ''barrel shifters'' serait donc un cout en circuits qui ne servirait que rarement et n'en vaut pas la chandelle. Il faut noter que sur les CPU 32 bits, les multiplications peuvent être exécutées dans la FPU ou dans un multiplieur séparé, tout dépend de si le CPU exécute les multiplications dans la FPU ou pas. {|class="wikitable" |- ! Voie d'émission n°1 !! Voie d'émission n°2 !! Voie d'émission n°3 |- | ALU entière || ALU entière || FPU |} Reste à connecter les unités de calcul à l'unité d'émission (indirectement, car il y a des étages de pipeline entre les deux). Une implémentation basique utilisée un port d'émission par unité. Prenons l'exemple d'un CPU superscalaire avec 2 ALU entières, un multiplieur entier, un ''barrel shifter'' une FPU, et une unité mémoire. Vu qu'il y a 6 unités, cela fait donc 6 ports d'émission. C'est beaucoup, l'unité d'émission est très complexe. Suffisamment complexe pour se demander s'ils n'existerait pas des optimisations qui permettraient d'en réduire le nombre sans trop réduire les performances, ni le nombre d'ALU. Et cela nous amène à la section suivante. ===Le partage des ports d'émission=== La présence d'un circuit multiplieur et d'un ''barrel shifter'' pose des problèmes pour le partitionnement. Il est possible d'avoir un port d'émission dédié au multiplieur, et un autre au ''barrel shifter'', qui sont séparés de ceux pour l'ALU entière. Il est alors possible d'émettre une opération entière, une multiplication et un décalage, simultanément. Nous parlerons de '''partitionnement entier''' pour désigner cette possibilité. [[File:Partitionnement superscalaire entier.png|centre|vignette|upright=2|Partitionnement superscalaire entier.]] Le partitionnement entier est cependant très rare, car le gain en performance serait trop faible au regard des couts en circuit. Quelques processeurs historiques l'utilisaient, mais ce n'est plus le cas de nos jours. La raison est que les multiplications et les décalages sont assez rares dans le code, ce qui fait que le port d'émission associés sont sous-utilisés. Et un port d'émission a un cout en circuit assez important. Et c'est sans compter qu'ajouter des ports sur le banc de registres juste pour un multiplieur sous-utilisé n'en vaut pas la peine. L'idéal serait de trouver un moyen de réduire le nombre de ports d'émissions, sans réduire le nombre d'unités de calcul. [[File:Emission multiple des opérations entières, implémentation naive.png|vignette|Émission multiple des opérations entières, implémentation naive]] La solution consiste à '''partager les ports d'émission''', à savoir connecter un port d'émission à plusieurs unités de calcul. L'exemple classique est justement lié aux circuits multiplieur et ''barrel shifters''. L'idée est que le multiplieur et le ''barrel shifter'' sont mis sur le même port d'émission qu'une ALU entière. Prenons par exemple le processeur à double émission entière illustré ci-contre. Un port d'émission est relié à une ALU entière seule, et l'autre port d'émission alimente la seconde ALU entière et le multiplieur. La conséquence est que le processeur peut émettre deux opérations entières, dont une multiplication. Si on avait utilisé un port d'émission dédié au multiplieur, il aurait été possible d'émettre deux opérations entières et une multiplication. On perd donc une voie, mais on gagne en flexibilité et on économise des circuits. Notons que ce partage signifie aussi que le multiplieur partage les ports sur le banc de registre, ce qui fait une double économie. [[File:Emission multiple des opérations entières, implémentation courante.png|centre|vignette|upright=1.5|Émission multiple des opérations entières, implémentation courante]] En général, le ''barrel shifter'' et le multiplieur sont placés sur deux voies d'émission séparés, ce qui permet d'émettre un décalage et une multiplication en même temps. Les mettre sur le même port ne le permettrait pas. {|class="wikitable" |- ! Voie d'émission n°1 !! Voie d'émission n°2 !! Voie d'émission n°3 |- | * ALU entière * Multiplieur | * ALU entière * Barrel shifter | FPU |- | * Additions, soustractions, opérations bit à bit, etc. * Multiplication | * Additions, soustractions, opérations bit à bit, etc. * Décalages et rotations | Opération flottante |} Terminons concernant les multiplieurs avec une dernière subtilité. Pour rappel, les processeurs modernes intègrent en réalité deux multiplieurs différents. Le résultat d'une multiplication est très large et prend deux registres. Par exemple, une multiplication de deux opérandes 64 bits donne un résultat sur 128 bits, ce qui ne tient pas dans un registre, mais en prend deux. L'instruction de multiplication classique ne calcule que les 64 bits de poids faible. mais elle est complétée par d'autres instructions de multiplication qui peuvent faire le calcul complet. Pour résoudre le problème, les processeurs modernes intègrent deux instructions de multiplications : une qui calcule les bits de poids faible du résultat, une autre qui calcule les bits de poids fort. Et ces deux instructions sont associées à deux multiplieurs séparés, aux circuits différents. Le multiplieur qui calcule les bits de poids faible du résultat est appelé le ''low multiplier'', l'autre est appelé le ''high multiplier''. Le second multiplieur est souvent placé sur un second port d'émission, différent du premier multiplieur, partagé avec une ALU. L’avantage de mettre les deux multiplieurs sur deux ports d'émission différents est qu'une multiplication complète peut être réalisée en utilisant les deux multiplieurs en parallèle. Les deux sous-multiplications sont émises en même temps, exécutées en même temps. pas besoin d'exécuter l'une puis la seconde, d'où un gain performance appréciable. Nous venons de voir comment partager un port d'émission entre une ALU et un multiplieur. Mais au-delà de cet exemple, le partage des ports d'émission peut appliquer à n'importe quelle unité. Prenons l'exemple d'un CPU superscalaire avec 4 ALU entières, une FPU, et une unité mémoire. Vu qu'il y a 6 unités, cela fait donc 6 ports d'émission. Cependant, il est rare qu'un bloc de 6 instructions contienne exactement 4 opérations entières, une opération flottante, et un accès mémoire. Beaucoup de ports d'émission seraient sous-utilisés. La solution est de partager des ports d'émission, pour passer à la quadruple émission. Le CPU peut alors répartir 4 instructions sur les ports d'émission adéquats, à chaque cycle. Le CPU a donc 4 ports de décodage et 6 ports d'émission. Une implémentation classique permet n'importe quelle combinaison de 4 instructions compatibles avec les 6 unités. Par exemple, il devient possible d'émettre simultanément 4 additions, la combinaison 2 * INT + FLOAT + MEM, 3 * INT + MEM, et bien d'autres. L'exemple relie une ALU entière, la FPU et l'unité mémoire sur le même port. Le CPU a alors quatre ports d'émission, qui ne sont pas "identiques". Trois d'entre eux sont reliés à une ALU entière chacun, rien d'autre. Le quatrième est lui relié à une ALU entière, la FPU et l'unité mémoire. On économise alors deux ports d'émission, mais cela entraine l'apparition de contraintes d’appariement. Le CPU peut émettre 3 opérations entières + n'importe quelle autre opération. Mais les autres combinaisons sont interdites. Le CPU peut émettre 6 opérations entières simultanément, grâce à 6 ALU entières. Les accès mémoire ou opérations flottantes ne viennent pas en plus, mais en remplacement des opérations entières. Regrouper plusieurs ALU sur un même port d'émission est donc à l'origine de dépendances structurelles, de nouvelles contraintes d’appariement. Dans certaines situations de '''''port contention''''', on a assez d'ALU pour exécuter N micro-opérations, mais la répartition des ALUs sur les ports l'empêche. Impossible d'émettre deux micro-opérations sur deux ALU si elles sont sur le même port d'émission. On peut réduire les cas de ''port contention'' en répartissant correctement les ALU sur les ports d'émission, mais sans jamais les supprimer complétement. Un exemple est celui du PowerPC 604. Il implémentait la quadruple émission, alors qu'il avait 5 unités de calcul. Mais deux unités partageaient un même port d'émission. Dans le détail, il avait deux ALU entières, un circuit multiplieur/diviseur, une FPU, une unité mémoire. La FPU et le multiplieur étaient sur le même port d'émission. En conséquence, le CPU pouvait émettre seulement les combinaisons INT + INT + MEM + (MUL/FLOAT). : Il est intéressant de voir l'exemple d'un processeur superscalaire parfait, sans contraintes d'appariement, à N voies. Pour cela, on part d'un processeur non-superscalaire, avec M unités de calcul. Un tel processeur peut s'implémenter avec N ports d'émission, chacun relié à M unités de calcul possibles, ce qui fait que les M unités de calcul sont dupliquées en N exemplaires. Si on prend par exemple un CPU non-superscalaire avec une ALU, une FPU, une unité mémoire et une unité de branchement, un port d'émission sera relié à une ALU, une FPU, une unité mémoire et une unité de branchement. Dupliquer les ALU entière est une première étape, mais il est aussi possible de dupliquer d'autres unités, comme l'unité mémoire ou l'unité de branchement. La rentabilité en performance dépend surtout de si cela autorise des paires d'instructions fréquentes. Le cout en circuit n'est aussi pas le même. Sur les CPU superscalaires ''in-order'', seuls les ALU et FPU sont dupliquées. ===Les CPU superscalaires mélangent les trois techniques=== Les CPU superscalaires utilisent toutes les techniques à la fois : partitionnement, duplication de l'ALU entière, duplication des autres unités fonctionnelles. Chaque processeur utilise ces trois stratégies à sa sauce, en fonction de son budget en transistor. Les quatre techniques ont cependant un cout en circuit différent, et donnent des gains de performances eux aussi forts différents. * Le '''partitionnement superscalaire''' a un cout en circuit modéré, car on n'a pas besoin de dupliquer d'ALU. * La '''duplication des ALU entières''' est la technique la plus importante : elle a un cout en circuit modéré, et donne des gains en performance impressionnants. * Le '''partage des ports d'émission''' réduit le cout en circuit du CPU, pour des pertes de performances mineures si bien fait. L'usage du partitionnement seul, sans duplication, était surtout le fait des processeurs commercialisés entre les années 1989 et 1993. De nos jours, les processeurs utilisent un mix différent d'unités, et leur partitionnement est différent. Ils tendent à s'adapter au mieux aux besoins des programmes exécutés. D'après diverses estimations, un programme informatique exécute environ 40% de son code dans des instructions arithmétiques, un autre 40% dans les accès mémoire, et un le reste pour... le reste. L'idéal est donc de pouvoir émettre autant de µops mémoire que de µops entières, à chaque cycle. Dit autrement, la largeur du processeur doit pouvoir être répartie ''moitié opération - moitié mémoire''. Bien évidemment, le processeur peut gérer d'autres combinaisons. Pour s'adapter, les processeurs dupliquent d'abord les ALU entières, suivi par l'unité mémoire, et enfin par les autres unités. Pour s’adapter au mieux, un processeur à N voies doit disposer d'au moins N/2 ALU entières, l'idéal étant d'aller au-delà. Les CPU superscalaires historiques avaient 2 ALU pour de la quadruple émission, ce qui était très limite. Les CPU superscalaires modernes mettent le paquet niveau ALU entière. La plupart des CPU x86 modernes descendent rarement sous les 2/3, la plupart remplissent presque la totalité de la largeur du CPU. Pour donner un exemple, la micro-architecture Golden Cove d'Intel est un processeur à 6 voies, avec 5 ALU entières. Ensuite, les processeurs modernes dupliquent leurs unités mémoire. Par exemple, la micro-architecture ''Golden Cove'' d'Intel dispose de trois unités mémoire : deux spécialisées dans les écritures, trois dans les lectures. La micro-architecture ''Gracemont'' utilise la quintuple émission et peut émettre 4 µops entières et 4 µops mémoire. Notez que le ratio est équilibré entre opérations entières et mémoire, avec soit égalité, soit un avantage pour les ALU entières. Une autre régularité est que la largeur totale du processeur peut être occupée par des opérations entières/mémoire. Pour le dire autrement, sur un processeur quadruple émission, il pourra émettre un mix de 4 instructions entière/mémoire, avec zéro opération flottante. Idem avec des processeurs triples ou quintuple émission, voire plus. Si le processeur émet une opération flottante, elle remplace une instruction entière ou mémoire. Prenons l'exemple des processeurs AMD K6, sortis en 1997, juste avant les AMD Athlon. Ils avaient 10 unités de calcul, 6 ports d'émission, 4 ports de décodage. Voici exactement comment étaient répartie les unités de calcul sur les ports d'émission. C'était des processeurs quadruple émission, ce qui fait que la moitié de la largeur du processeur sert pour des opérations entières, et l'autre moitié pour les accès mémoire. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 ! |- | ALU entière || ALU entière || FPU || Unité de branchement || Unité LOAD || Unité STORE |- | || Multiplieur || || || || Unité de calcul d'adresse (instruction LEA) |- | || ''Barrel shifter'' || || || || Unité pour les instructions PUSH |} Les micro-architectures K7, K8 et K10, utilisées sur les anciens CPU AMD Athlon, sont un exemple moins représentatif. Ces micro-architectures utilisent le partage des ports d'émission au maximum. C'était des micro-architectures à triple émission, avec six ports d’émission. Il y avait trois ports d'émission pour les opérations flottantes, et trois ports d'émission pour les micro-opérations entières/mémoire. Il y avait trois ALU entières et trois unités mémoire, chaque ALU partageait un port d'émission avec une unité mémoire. Elle pouvait donc émettre soit trois opérations entières, soit trois µops mémoire, soit un mix des deux. En plus des mix avec les opérations flottantes et branchements. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 ! |- | ALU entière || ALU entière || ALU entière || FADD || FMUL || FPU (autres opérations) |- | Multiplieur || || || || || |- | LOAD/STORE || LOAD/STORE || LOAD/STORE || || || |} Les CPU superscalaires larges dupliquent les multiplieurs et/ou les ''barrel shifters'', et même les unités de branchement. La raison est que quand la largeur du CPU augmente, le processeur traite des paquets d'instructions de plus en plus gros. Par exemple, pour un CPU à 10 voies, cela veut dire émettre 10 instructions en même temps. Il est alors plus fréquent d'avoir deux multiplications, deux décalages, deux accès mémoire, deux branchements. Par exemple, la moyenne est d'une multiplication tous les 5 instructions. Les CPU superscalaires à 10 voies ont donc tendance à avoir deux multiplications par paquet d'instruction, en moyenne. Avoir deux multiplieurs est alors un gain en performance clair. Par exemple, la micro-architecture ''Golden Cove'' a deux ''barrel shifters''. C'est une architecture à 6 voies. {|class="wikitable" |- ! Port d'émission n°1 !! Port d'émission n°2 !! Port d'émission n°3 !! Port d'émission n°4 !! Port d'émission n°5 !! Port d'émission n°6 !! Port d'émission n°7 !! Port d'émission n°8 !! Port d'émission n°9 !! Port d'émission n°10 |- | ALU entière || ALU entière || ALU entière || ALU entière || ALU entière || LOAD || LOAD || LOAD || STORE || STORE |- | ''Barrel shifter'' || Multiplieur || Multiplieur || ''Barrel shifter'' || || || || || || |- | Branchements || Diviseur || || Branchements || || || || || || |} ==Les µops mémoire sur les processeurs superscalaires== Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. Voyons ces deux cas dans le détail. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== Une autre tendance est de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ==Les processeurs à double émission== Dans cette section, nous allons étudier la forme la plus basique de superscalarité : les processeurs à double émission. De tels processeurs n'utilisent généralement pas l'exécution dans le désordre, mais il y a quelques exceptions. Les premiers CPU à double émission sont assez représentatifs des CPU à double émission modernes. Il faut dire que se limiter à deux voies ne permet pas de faire beaucoup de choses. En général, les processeurs à double émission ne dupliquent pas l'unité mémoire. Cela ne servirait à rien de faire deux accès mémoire simultanés sur un processeur à double émission. Et il en est de même pour la FPU et l'unité de branchement. Ils se limitent à deux techniques : dupliquer l'ALU entière, le partitionnement. Quant au partage des ports d'émission, c'est là que les ennuis commencent. L'implémentation la plus simple de la double émission utilise deux ports d'émission. Elle donne l'illusion que le processeur dispose de '''deux pipelines séparés'''. Ils partent d'un processeur non-superscalaire, et ajoutent un second pipeline, en sortie de l'unité d'émission. Le second pipeline permet l'émission parallèle d'une seconde instruction. Le problème est que les deux ports d'émission sont reliés à un grand nombre d'unités de calcul. L'unité d'émission doit donc partager des ports d'émission, et c'est là que les subtilités commencent. Il faut répartir les unités au mieux pour éviter d'avoir trop de contraintes d’appariement problématiques. Le second pipeline n'est pas identique au pipeline originel, sur plusieurs points. Premièrement, il n'a pas exactement les mêmes circuits. Le second pipeline est généralement composé d'une unité de calcul, et permet d'exécuter une seconde opération de calcul, pas plus. En conséquence, les contraintes d'appariement assez fortes. Et il n'a pas forcément la même longueur, il peut avoir des étages en moins ou en plus. Il s'agit de la forme la plus simple de processeur superscalaire et nous allons leur consacrer cette section. Une autre implémentation utilise de la double émission, mais avec 3, 4, 5, ... ports d'émission. Le processeur ne donne plus l'impression d'avoir deux pipelines séparés, mais gagne en flexibilité. Les contraintes d'appariement sont plus faibles, moins nombreuses, moins problématiques. Mais le cout en circuit n'est pas négligeable. Voici le tableau chronologique disant quel CPU utilisait quelle technique. * Les CPU en vert ajoutaient un second pipeline pour les opérations flottantes. * Les CPU en rouge ajoutaient un second pipeline pour les opérations entières. * Les CPU en jaune utilisaient un ''partitionnement complet'' entre ALU, FPU et unité mémoire. * Les CPU sans couleur utilisaient un grand nombre d'unités, avec beaucoup de ports d'émission. * Les CPU barrés utilisent la triple ou quadruple émission et seront vu dans la suite. {|class="wikitable" |- ! !! IBM !! HP PA-RISC !! SPARC !! Intel!! Alpha !! Autres |- ! 1989 | class="f_vert | RS/6000 || || || class="f_rouge | i960CA || || |- ! 1990 | || || || || || |- ! 1991 | || || || || || Motorola 88110 |- ! 1992 | class="f_vert | RSC || class="f_vert | PA 7100 || <s>SuperSPARC</s> || || class="f_jaune | 21064 || |- ! 1993 | class="f_vert | PPC 601 || || class="f_jaune | HyperSPARC || class="f_rouge | Pentium || || |- ! 1994 | <s>POWER 2</s> || PA 7200 || || || <s>21164</s> || |} ===La double émission entière-flottante=== L'exemple le plus simple utilise un second pipeline pour les opérations flottantes, séparé du pipeline originel. L'ajout d'un pipeline flottant permet d'émettre une micro-opération entière en même temps qu'une micro-opération flottante, ce qui s'appelle la '''double émission entière-flottante'''. La micro-opération entière s'exécute dans l'ALU entière, la micro-opération flottante dans la FPU. Les exemples les plus notables sont le PA-RISC 7100, le POWER 1 (RIOS .& et .9) et le PowerPC 601. L'Apollo DN10000 et l'Intel i860 utilisaient aussi cette forme de double émission, mais étaient des prototypes de CPU VLIW. [[File:Double émission entière-flottante.png|centre|vignette|upright=2|Double émission entière-flottante]] Pour bien expliquer les choses, prenons le processeur illustré ci-dessous, avec deux ''back-end'' séparés : un pour la FPU, et un autre pour le reste. Les instructions entières et mémoire passent toutes par le ''back-end'' généraliste. [[File:CPU non-superscalaire basique, avec une FPU.png|centre|vignette|upright=1.5|CPU non-superscalaire basique, avec une FPU]] Les deux ''back-end'' sont des pipelines séparés, mais il faut que l'unité d'émission soit spécialement conçue pour en profiter. Un processeur simple émission peut démarrer une seule µop par cycle : soit la µop est envoyée au ''back-end'' flottant, soit elle est envoyée au ''back-end'' général. Avec la double émission entière-flottante, le processeur peut envoyer une µop dans chaque pipeline, à chaque cycle. Vu que la FPU et son banc de registre forment déjà un pipeline séparé, les seules modifications se font au niveau du ''front-end'', unité d'émission inclue. {| |- |[[File:CPU simple émission avec une FPU.png|centre|vignette|upright=1.5|CPU simple émission avec une FPU.]] |[[File:Double émission entière flottante avec un second pipeline.png|centre|vignette|upright=1.5|CPU avec un second pipeline flottant.]] |} Notez que le terme double émission ''entière''-flottante est un peu trompeur. En réalité, la technique permet d'émettre une instruction flottante avec n'importe quelle autre instruction non-flottante, avec des restrictions pour les branchements. Il est par exemple possible d'exécuter un calcul flottant en parallèle d'un accès mémoire. Sur les CPU 32 bits, il est possible de faire les multiplications dans la FPU. Pour rappel, la FPU contient un multiplieur entier 53-54 bits, pour multiplier les mantisses de deux flottants 64 bits, qu'on peut utiliser pour faire les multiplications entières 32 bits. Cela permet d'émettre une multiplication en même temps qu'une opération entière dans l'ALU. Ainsi, le CPU pouvait émettre deux instructions : une opération entière de base, avec soit une multiplication, soit une opération flottante. En clair, une émission de type INT + (MUL /FLOAT). Par contre, on perd la possibilité d'émettre une multiplication entière avec une opération flottante, mais c'était un compromis qui en valait la peine. Le CPU PA-RISC 7100 implémentait cette optimisation. Il s'agissait d'un CPU double émission entière-flottante, qui utilisait donc un partitionnement assez simple. Le combo double émission entière-flottante + multiplication dans la FPU permettait ici de gagner en performance, pour un cout en matériel très limité. D’autant plus limité que le jeu d'instruction prévoyait le reconditionnement de la FPU. Les multiplications ne lisaient pas leurs opérandes dans les registres entiers, mais dans les registres flottants. ===La double émission entière=== Une autre possibilité ajoute un second pipeline, capable d'exécuter des opérations entières simples, appelé le pipeline entier. Le processeur supporte alors la '''double émission entière''', à savoir qu'ils peuvent émettre une opération entière en plus d'une autre instruction. L'opération entière en question est une opération simple, comme une addition, une soustraction, une opération bit à bit, une comparaison, etc. Un exemple de CPU double émission de ce type est celui du Power PC 440, un CPU destiné à l'embarqué. Vu qu'il était destiné à l'embarqué, il n'avait pas de FPU, il n'avait pas besoin de manipuler de nombres flottants. Il n'a donc que des ALU entières, un multiplieur et une unité mémoire. Il dispose de deux ports d'émission. Le premier est connecté à une ALU entière et un circuit multiplieur, le second est relié à l'unité mémoire et une seconde ALU entière. Pour résumer : une ALU entière par port, l'unité mémoire sur un port, le multiplieur sur l'autre. Une organisation simple, mais très efficace ! [[File:PowerPC 440.png|centre|vignette|upright=2|Micro-architecture du PowerPC 440.]] Le processeur précédent n'avait pas de FPU, ce qui fait qu'il n'avait pas grand chose à connecter sur ses ports d'émission. le partage des ports d'émission était assez simple. Aussi il est intéressant de voir des exemples avec une FPU. Les exemples classiques sont les processeurs Intel i960 et Pentium. L'implémentation était différente entre l'i960 et le Pentium : Le Pentium avait une seconde ALU entière, alors que le i960 rusait. Le Pentium dupliquait l'ALU entière, pour créer un second pipeline entier. Sur le Pentium originel, les contraintes d'appariement étaient assez strictes. Le Pentium avait une FPU, mais ne pouvait pas l'utiliser en double émission. Il lui était impossible d'exécuter une opération flottante en même temps qu'une opération entière (branchements inclus). Le Cyrix 6x86 était une sorte de Pentium produit par l'entreprise Cyrix, avec une micro-architecture différente. Il avait des contraintes d’appariement réduites. Par exemple, il pouvait émettre une opération flottante en parallèle d'une opération entière. Nous détaillerons ces processeurs dans le prochain chapitre, qui porte justement sur les CPU superscalaires x86. [[File:CPU simple et double émission avec un second pipeline entier.png|centre|vignette|upright=2.5|CPU simple et double émission avec un second pipeline entier.]] L'Intel i960 n'avait pas de FPU, juste une ALU entière et une unité mémoire. Il utilisait l'unité mémoire comme une seconde ALU entière limitée. Pour rappel, une unité mémoire contient une AGU, à savoir une ALU spécialisée dans les calculs d'adresse. Elle est capable de faire des additions, soustractions, et quelques décalages limités (des décalages par 2, 4, 8, pas plus). L'Intel i960 ajoutait des connexions entre AGU et banc de registre, afin que celle-ci serve de seconde unité de calcul. L'AGU est déjà reliée aux deux ports de lecture, pour lire les opérandes du calcul d'adresse, il suffit de la connecter sur le port d'écriture. Le processeur peut alors émettre deux instructions/µops entières, sous certaines conditions. [[File:Emission multiple des opérations entières, double émission.png|centre|vignette|upright=1.5|Émission multiple des opérations entières, double émission.]] le processeur alpha 21264 implémentait cette optimisation, mais c'était un processeur quadruple émission, aussi je le mentionne juste en passant. Il avait deux ALU entières et deux AGUs, les deux AGUs pouvaient faire des additions/soustractions et des opérations logiques. ===La double émission INT-FLOAT-MEM=== La '''double émission INT-FLOAT-MEM''' utilise la technique du partitionnement superscalaire, vue plus haut, avec cependant une petite subtilité. Elle utilise précisément une ALU entière, une FPU et une unité mémoire. En théorie, le partitionnement devrait donner un CPU triple émission, pas double émission. Et c'est là que la différence entre ports d'émission et de décodage devient intéressante. L'idée est d'avoir une unité d'émission avec deux ports de décodage, et 2 ports d'émission (un par unité). Les contraintes d’appariement varient alors suivant le processeur, mais la règle générale est que si deux micro-opérations tombent dans deux unités différentes, on peut les émettre en même temps. En clair, on peut émettre une opération entière avec une opération flottante, une opération entière avec un accès mémoire, une opération flottante avec un accès mémoire. Et cela rend le partitionnement bien plus performant que prévu. L'avantage sur la double émission flottante est que les combinaisons INT + MEM et FLOAT + MEM étaient autorisées. Le processeur pouvait donc émettre une opération en parallèle d'un accès mémoire, ce qui est un doublet d'instruction assez fréquent. Le gain en performance par rapport à la doublé émission entière-flottante était substantiel. Un détail est que cette forme de double émission ne peut pas s'interpréter comme un processeur à deux pipelines. Le processeur n'a pas deux ports d'émission, mais trois, et les contraintes d'appariement collent assez mal avec deux pipelines séparés. Il est possible d'interpréter le tout comme un CPU à trois pipelines, mais qui n'autorise que les paires d'instructions, mais ce serait assez tiré par les cheveux. Aussi, la notion de "plusieurs pipelines indépendants" ne marche pas pour décrire ce genre de processeurs superscalaires. Leur nombre de voies reste de deux, mais le nombre de pipelines est indéterminé. Les exemples classiques sont l'HyperSPARC, l'alpha 21064 et le PowerPC 603. Le PowerPC 603 implémentait une double émission "parfaite". C'est à dire que toutes les combinaisons possibles à deux instructions étaient possibles, que ce soit INT + FLOAT, INT + MEM, FLOAT + MEM. En plus de cette double émission "parfaite", le CPU utilisait la technique dite de ''branch folding'', qui permettait d'exécuter un branchement sans utiliser d'unité de calcul, ce qui fait qu'il était qualifié de ''CPU 2-way + Branch''. A l'opposé, le processeur alpha 21064 avait pas mal de contraintes d’appariement, à savoir que certaines combinaisons de deux instructions étaient interdites, bien que compatibles avec les unités disponibles. Une des raisons est que le banc de registre n'avait pas assez de ports de lectures, ce qui fait que certaines combinaisons avec une écriture mémoire n'étaient pas permises. Les concepteurs avaient volontairement inséré ces contraintes afin de simplifier le design du processeur. ===La double émission avec beaucoup d'unités entières=== Nous venons de voir qu'il est possible d'utiliser efficacement un surplus de ports d'émission, afin de réduire les contraintes d'appariement. Des ports d'émission qui étaient partagés sont simplement dupliqués et les ALU sont réparties sur des ports d'émission différents. Il est possible de pousser cette logique encore plus loin, en utilisant un grand nombre d'unité de calcul. L'exemple historique le plus impressionnant est celui du Motorola 88110, un CPU double émission qui possède pas moins de 10 unités ! Il contient deux ALU entières, un circuit multiplieur, un circuit diviseur, une unité de manipulation de bits, une unité mémoire, un additionneur flottant, et deux unités graphiques pour des calculs impliquant des pixels. Le multiplieur est utilisé à la fois pour les multiplications entières, flottantes et le produit de deux pixels. Idem pour le diviseur, qui sert pour les divisions entières et flottantes. Et le partitionnement est poussé au point où chaque unité a son propre port d'émission. La conséquence est que les contraintes d'appariement du Motorola 88110 sont simples : tant que deux µops atterrissent dans des unités séparées, la paire est permise. Il est par exemple possible d'émettre simultanément deux opérations entières simples, vu qu'il y a deux ALU. Ou encore, d'émettre une addition et une multiplication, une multiplication et un décalage, un décalage et une division, une addition entière et une addition flottante, etc. Par contre, impossible d'émettre simultanément deux multiplications, deux divisions, deux opérations flottantes, deux manipulations de bit, deux accès mémoire, etc. De plus, le partage du multiplieur fait qu'on ne peut pas émettre une multiplication entière avec une multiplication flottante. Idem avec les divisions, vu que le diviseur est aussi partagé. Ce sont les seules contraintes, et c'est très peu comparé aux autres processeurs double émission. ===La double émission de µops dépendantes=== De rares processeurs pouvaient émettre deux µops dépendantes simultanément. Leur idée était de fusionner deux µops consécutives en une seule µops, mais qui faisait plusieurs calculs consécutifs. L'unité de calcul entière était fortement modifiée, et devenait capable d’exécuter deux µops simples consécutives. Les exemples les plus connus sont le processeur SuperSPARC, le processeur POWER 1, et le Motorola 68060. Un exemple est celui du processeur POWER 1, qui pouvait émettre deux additions si elles avaient une dépendance. Pour cela, l'ALU entière intègre un additionneur trois-opérande, ce qui permet de faire le calcul A + B + C. Le processeur SuperSPARC allait plus loin. Il disposait de trois ALU entières enchainées. Les deux premières ALU pouvaient travailler indépendamment les unes des autres. Le processeur peut émettre deux µops entières dedans, les deux ALUs vont en traiter une chacune. Mais les deux µops sont dépendantes, les deux ALU envoient leurs résultat àç une troisième ALU, qui combine les deux résultats. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le parallélisme mémoire | prevText=Le parallélisme mémoire | next=Les processeurs superscalaires out-of-order | nextText=Les processeurs superscalaires out-of-order }} </noinclude> ak766t69f1f3lcj9n9y6jvnpjja14f0 Fonctionnement d'un ordinateur/Les jeux d'instructions 0 69027 774181 773574 2026-10-04T23:20:50Z Mewtow 31375 /* Les processeurs avec plusieurs jeu d'instruction */ 774181 wikitext text/x-wiki Les instructions d'un processeur dépendent fortement du processeur utilisé. Le '''jeu d'instructions''' d'un processeur définit la liste des instructions supportées, ainsi que la manière dont elles sont encodées en mémoire. Le jeu d'instruction des PC actuels est le x86, un jeu d'instructions particulièrement ancien, apparu en 1978. Les macintoshs utilisent un jeu d'instruction différent : le PowerPC. Mais les architectures x86 et Power PC ne sont pas les seules au monde : il existe d'autres types d'architectures qui sont très utilisées dans le monde de l’informatique embarquée et dans tout ce qui est tablettes et téléphones portables derniers cris. On peut citer notamment les architectures ARM, MIPS et SPARC. Pour résumer, il existe différents jeux d'instructions, que l'on peut classer suivant divers critères. ==Les jeux d'instruction RISC vs CISC== Dans le chapitre précédent, nous avons vu comment sont encodées les instructions, ainsi que de nombreux paramètres : les modes d'adressage supportés, leur encodage, la taille des instructions, l'encodage de l'opcode, etc. Et tous ces paramètres sont suffisant pour parler des processeurs RISC et CISC. Il s'agit d'une classification qui sépare les processeurs en deux catégories : * les '''RISC''' (''reduced instruction set computer''), au jeu d'instruction simple ; * et les '''CISC''' (''complex instruction set computer''), qui ont un jeu d'instruction étoffé. Reste à expliquer ce que l'on veut dire quand on parler de jeu d'instruction "simple" ou "complexe". Et la différence n'est pas simple. Surtout que le terme CISC regroupe, comme on le verra, des architectures bien différentes. ===Les différences entre CISC et RISC=== La différence la plus intuitive est le nombre d'instructions supporté. Les processeurs CISC supportent beaucoup d'instructions, , alors que les processeurs RISC se contentent d'un petit nombre d'instructions assez simples. Un autre critère, très lié au précédent, est la complexité des instructions en question. Les processeurs CISC incorporent souvent des instructions complexes, comme le calcul de la division, de la racine carrée, de l'exponentielle, des puissances, des fonctions trigonométriques. Plus fréquent, on trouvait des instructions de contrôle élaborées pour gérer les appels de fonction, simplifier l'implémentation des boucles, etc. La différence principale fait la distinction entre les architectures LOAD-STORE (RISC) et celles qui ne le sont pas (CISC). Les processeurs RISC sont des '''architectures LOAD-STORE'''. Sur celles-ci, seules les instructions d'accès mémoire peuvent lire ou écrire en mémoire. Les autres instructions ne peuvent accéder qu'aux registres : elles lisent leurs opérandes dans les registres et enregistrent leur résultat dans les registres. En conséquence, les instructions de calcul ne peuvent prendre que des noms de registres ou des constantes comme opérandes, via les modes d'adressage immédiat et à registre. Pour le dire autrement, elles ne gèrent que les instructions reg-reg et cst-reg, pas les instructions ''load-op''. La distinction se fait uniquement au niveau des instructions de calcul/branchement. Les premiers processeurs RISC se contentaient de deux instructions d'accès mémoire : LOADq et STORE. Elles ont d'ailleurs donné leur nom à ce type d'architecture. Mais dans les faits, les processeurs RISC modernes peuvent gérer d'autres instructions d'accès à la mémoire. Par exemple, les processeurs ARM7 supportent des instructions de copie mémoire-mémoire nommées CPYP, CPYM et CPYE. Et ce n'est pas incompatible avec un processeur RISC, ni avec une architecture LOAD-STORE. De telles instructions ont beau être assez complexes, ça reste des instructions d'accès mémoire. Même si elles font plusieurs accès mémoire par instruction, c'est comme si on avait fusionné un LOAD suivi d'un STORE en une seule instruction. Les architectures LOAD-STORE sont les seules à avoir une stricte séparation entre instructions d'accès mémoire et instructions de calcul. À l'opposé, les processeurs CISC ne sont pas des architectures LOAD-STORE, leurs instructions de calcul/branchement peuvent effectuer des accès mémoire, soit pour aller chercher leurs opérandes en RAM, soit pour écrire un résultat. Elles peuvent même en faire plusieurs si plusieurs opérandes sont en mémoire, encore que ce ne soit pas possible sur tous les jeux d'instructions. Au minimum, les processeurs CISC gèrent les instructions ''load-op''. Quelques processeurs CISC anciens supportent tous les modes d'adressage possibles pour les opérandes et le résultat, qui peuvent tous trois être lus/écrits dans les registres ou la RAM. Les deux propriétés précédentes, à savoir un grand nombre d'instruction et des modes d'adressages complexes, se marient bien avec des instructions de longueur variable. Les instructions d'un processeur CISC sont de taille variable pour diverses raisons, mais la variété des modes d'adressage y est pour beaucoup. Quand une même instruction peut incorporer soit deux noms de registres, soit deux adresses, soit un nom de registre et une adresse, sa taille ne sera pas la même dans les trois cas. Les processeurs RISC ne sont pas concernés par ce problème. Les instructions de calcul n'utilisent que deux-trois modes d'adressages simples, qui demandent d'encoder des registres ou des constantes immédiates de petite taille. Les autres instructions se débrouillent avec des adresses et éventuellement un nom de registre. Le tout peut être encodé sur 32 ou 64 bits sans problèmes. {|class="wikitable" |+ Différences CISC/RISC |- ! Propriété !! CISC !! RISC |- ! scope="col" | Instructions | * Nombre d'instructions élevé, parfois plus d'une centaine. * Parfois, présence d'instructions complexes (fonctions trigonométriques, gestion de texte, autres). * Supportent des types de données complexes : texte, listes chainées, etc. * Instructions de taille variable. | * Faible nombre d'instructions, moins d'une centaine. * Pas d'instruction complexes. * Types supportés limités aux entiers (adresses comprises) et flottants. * Instructions de taille fixe. |- ! scope="col" | Modes d'adressage | * Support des instructions ''load op'', systématique * Souvent, support d'instructions multi-accès (plusieurs accès mémoires par instruction). | * Architecture LOAD-STORE. * Un seul accès mémoire par instruction maximum |- ! scope="col" | Registres | * Peu de registres : rarement plus de 16 registres entiers. | * Beaucoup de registres, souvent plus de 32. |} Les jeux d'instruction CISC sont moins nombreux que les jeu d'instructions RISC. Les jeux d'instructions RISC les plus connu sont ARM, RISC-V, MIPS, Power et PowerPC, PA-RISC, et de nombreux autres. Pour le CISC, le x86 et le z/ architecture d'IBm sont les deux jeux d'instruction restants. A vrai dire, si on omet les anciens jeux d'instruction CISC utilisés sur les anciens ''mainframes'', ce sont les deux seuls utilisés à l'heure actuelle. : Pour ceux qui veulent en savoir plus sur le jeu d'instruction et les processeurs, PA-RISC, ce site vous donnera toutes les informations possibles : [https://www.openpa.net/ Open PA-RISC]. ===Les performances relatives des CISC/RISC=== Pour comprendre quels sont les avantages et désavantages des processeurs CISC comparé au RISC, nous allons étudier leur performance et leur ''code density''. Pour ce qui est de la performance, les choses ne sont pas clairement tranchées. Pour comprendre pourquoi, nous allons partir d'une équation déjà abordée dans un chapitre antérieur, qui donne le temps que met un programme à s'exécuter. Le temps que met un programme pour s’exécuter est le produit : * du nombre moyen d'instructions exécutées par le programme ; * de la durée moyenne d'une instruction, en seconde. : <math>T = N \times T_\text{instruction}</math>, avec N le nombre moyen d'instruction du programme et <math>T_\text{instruction}</math> la durée moyenne d'une instruction. Le nombre moyen d'instructions exécuté par un programme s'appelle l'''Instruction path length'', ou encore '''longueur du chemin d'instruction''' en français. Si on utilise le nombre ''moyen'' d’instructions, c'est car il n'est pas forcément le même d'une exécution à l'autre, notamment en présence de conditions ou de boucles. Le processeurs CISC intègrent des instructions complexes, que les processeurs RISC doivent émuler à partir d'une suite d'opérations plus simples. Par exemple, les instructions ''load-op'' sont émulées avec une instruction LOAD suivie d'une instruction de calcul. En clair, les processeurs CISC ont un avantage pour le paramètre N, la longueur du chemin d'instruction. Par contre, il n'est pas garantit que les instructions complexes soient aussi rapides que la suite d'instruction RISC équivalente, car les processeurs RISC ont un avantage pour le terme <math>T_\text{instruction}</math>. Voyons pourquoi. Les processeurs CISC gèrent un grand nombre d'instructions et de modes d'adressage, ce qui les rend plus gourmands en transistors. Non pas qu'il faille ajouter plus de circuits de calcul, les CISC se débrouillent bien avec les circuits usuels pour les 4 opérations basiques. Le vrai problème est le support des nombreux modes d'adressage, des instructions de taille variable, et le grand nombre de variantes de la même opération. En conséquence, les circuits de contrôle du processeur sont plus complexes et utilisent beaucoup de transistors. Le budget en transistors utilisé pour les circuits de contrôle ne sont pas disponibles pour autre chose, comme de la mémoire cache ou des registres. De plus, cela a tendance à limiter la fréquence du processeur. Les processeurs RISC sont eux plus économes, ils ont moins d'instructions, moins de circuits de contrôle. Ils peuvent utiliser plus de transistors pour autre chose, souvent de la mémoire cache ou des registres. Les processeurs RISC peuvent se permettre d'utiliser beaucoup de registres, ils ont le budget en transistor pour. Et c'est sans compter que la simplicité des circuits de contrôle et des connexions intra-processeur (le bus interne au CPU qu'on verra dans quelques chapitres) fait qu'on peut faire fonctionner le processeur à plus haute fréquence. Si les registres sont plus nombreux sur les architectures RISC, ce n'est pas qu'une question de budget en transistors. Une autre raison est que les architectures LOAD-STORE ont besoin de plus de registres pour faire le même travail que les architectures à registres, du fait de l'absence d'instructions ''load-op''. La ''register pressure'' est donc légèrement plus importante, ce qui est compensée en ajoutant des registres. ===La densité de code relative entre RISC et CISC=== Si la performance ne donne pas de vainqueur clair entre CISC et RISC, il y a cependant un second critère sur lequel la victoire est bien plus nette. Il s'agit de la '''densité de code''', à savoir la taille des programmes mesurée en octets. De nos jours, la taille des programmes n'est pas importante au point d'être un problème, ce n'est pas ca qui prendra plusieurs gibioctets de mémoire. Par contre, au tout début de l'informatique, la mémoire était chère et limitée, avoir des programmes petits était un avantage clair. La taille d'un programme dépend de deux choses : le nombre d'instructions et leur taille. Et ces deux paramètres sont influencés par le caractère CISC/RISC. Comme dit plus haut, les architectures CISC ont un avantage pour le nombre d'instruction N, du fait de la présence d'instructions ''load-op'' et d'instructions complexes. Mais elles ont aussi un avantage pour la taille des instructions. Les instructions des processeurs CISC sont de taille variable, là où celles des processeurs RISC sont de taille fixe. Les instructions CISC ont une taille allant de très courtes à très longues, celles des RISC sont de taille moyenne. Mais le fait d'avoir des instructions très courtes l'emporte sur les processeurs CISC. Une minorité de processeurs RISC arrivent à obtenir une densité de code proche de celle des processeurs CISC. Pour cela, ils ont classes d'instructions de taille différente. Un exemple est jeu d'instruction RISC-V, où il existe des instructions "normales" de 32 bits et des instructions "compressées" de 16 bits. Le processeur charge un mot de 32 bits, ce qui fait qu'il peut lire entre une et deux instructions à la fois. Au tout début de l'instruction, un bit est mis à 0 ou 1 selon que l'instruction soit longue ou courte. Le reste de l'instruction varie suivant sa longueur. De telles instructions de taille quasi-variables permettent de grandement gagner en densité de code. Il faut noter que sur les processeurs modernes, avoir une bonne densité de code a un impact secondaire sur les performances. En effet, les processeurs modernes ont une mémoire cache qui sert à la fois pour les données, mais aussi pour les instructions chargées depuis la mémoire. Les instructions sont conservées dans le cache après leur exécution, pour une exécution ultérieure (ce qui implique une boucle). Si une instruction est dans le cache, elle est lue directement depuis le cache, sans passer par un long accès en mémoire RAM. Les processeurs modernes ont notamment un petit cache d'instruction, spécialement dédié aux instructions. Plus la densité de code est bonne, plus le cache d'instruction pourra contenir d'instructions, plus il sera efficace, plus il filtrera d'accès mémoire. ===Un petit historique de la distinction entre processeurs CISC et RISC=== Les jeux d'instructions CISC sont les plus anciens et étaient à la mode jusqu'à la fin des années 1980. À cette époque, on programmait rarement avec des langages de haut niveau et beaucoup de programmeurs codaient en assembleur. Avoir un jeu d'instruction complexe, avec des instructions de "haut niveau" facilitait la vie des programmeurs. Et leur meilleure densité de code était un sérieux avantage, car la mémoire était rare et chère. Au cours des années 70-80, la mémoire est devenue moins chère et les langages de haut niveau sont devenus la norme. Le contexte technologique ayant changé, les processeurs CISC étaient à réévaluer. Est-ce que les instructions complexes des processeurs CISC sont vraiment utiles ? Pour le programmeur qui écrit ses programmes en assembleur, elles le sont. Mais avec les langages de haut niveau, la réponse dépend de l'efficacité des compilateurs. Des analyses assez anciennes, effectuées par IBM, DEC et quelques laboratoires de recherche, ont montré que les compilateurs utilisaient les instructions ''load-op'' correctement, mais n'utilisaient pas les instructions complexes. Les analyses plus récentes fournissent la même conclusion. La faible densité de code n'était pas un problème, vu que les mémoires ont une capacité suffisante, encore que ce soit à nuancer. L'idée de créer des processeurs RISC commença à germer. Mais les processeurs RISC durent attendre un peu avant de percer. Par exemple, l'IBM 801, un processeur au jeu d'instruction très sobre, fût un véritable échec commercial. C'est dans les années 1980 que les processeurs possédant un jeu d'instruction simple devinrent à la mode. Cette année-là, un scientifique de l'université de Berkeley décida de créer un processeur possédant un jeu d'instruction contenant seulement un nombre réduit d'instructions simples, possédant une architecture particulière. Ce processeur était assez novateur et incorporait de nombreuses améliorations qu'on retrouve encore dans nos processeurs haute performances actuels, ce qui fit son succès : les processeurs RISC étaient nés. Les CISC et les RISC ont chacun des avantages et des inconvénients, qui rendent le RISC/CISC adapté ou pas selon la situation. Par exemple, on mettra souvent un processeur RISC dans un système embarqué, devant consommer très peu et être peu cher. Mais de nos jours, la performance d'un processeur dépend assez peu du fait que le processeur soit un RISC ou un CISC. Les processeurs modernes disposent de tellement de transistors qu'implémenter des instructions complexes a un impact négligeable. Pour donner une référence, le support des instructions complexes sur les processeurs x86 est estimé à 2 à 3% des transistors de la puce, alors que 50% du budget en transistor des processeurs modernes part dans le cache. Les processeurs actuels sont de plus en plus difficiles à ranger dans des catégories précises. Les processeurs actuels sont conçus d'une façon plus pragmatique : au lieu de respecter à la lettre les principes du RISC et du CISC, on préfère intégrer les instructions qui fonctionnent, peu importe qu'elles viennent de processeurs purement RISC ou CISC. Par exemple, les processeurs ARM récents ont intégré des instructions de copie mémoire, qui sont assez ''borderline'' et sont plutôt attendues sur les processeurs CISC. En parallèle des architectures CISC et RISC, d'autres classes de jeux d'instructions sont apparus. Nous verrons beaucoup de jeux d'instructions dans la suite du cours. Entre les architectures à capacité, les processeurs VLIW, les architectures ''dataflow'', les processeurs SIMD, les ''Digital Signal Processor'', les ''transport-triggered architectures'', les architectures associatives, les architectures neuromorphiques et les achitectures systoliques, il y aura de quoi faire. La plupart de ces jeux d'instructions sont implantés dans des processeurs spécialisés, qu'on fabrique pour une utilisation particulière. Malheureusement, nous ne pouvons pas en parler pour le moment. La suite de ce chapitre va se focaliser sur des jeu d'instruction proches du RISC ou du CISC. ==Les processeurs avec plusieurs jeu d'instruction== Il faut signaler que certains processeur supportent plusieurs jeu d'instruction à la fois. Les processeurs x86 des PC sont dans ce cas, mais aussi les processeurs ARM utilisés partout ailleurs. ===La compatibilité entre 32 et 64 bits=== Le cas des processeurs x86 est assez simple à comprendre. Ils ont beaucoup évolués dans le temps, commençant comme des processeurs 8 bits, puis 16 bits, suivis par des 32 bits et enfin des processeurs 64 bits. Et à chaque transition, le jeu d'instruction a évolué. Le x86 8 bit n'est pas le même que le x86 16 bits, qui est lui-même différent du x86 32 bits, etc. La raison est que pour étendre les adresses, il fallait modifier l'encodage des instructions. Et quitte à modifier l'encodage des instructions, autant en rajouter d'autres, et supprimer des instructions devenus inutiles. Une autre raison est qu'Intel et AMD ont rajouté des registres lors du passage au 32 bits, et idem lors du passage au 64 bits. Le jeu d'instruction x86 16 bits avait trop peu de registres pour des besoins actuels, ce qui fait qu'Intel et AMD ont décidé d'en rajouter. Et vu que rajouter des registres impactait l'encodage des instructions, cela ne pouvait se faire qu'à l'occasion d'un changement de jeu d'instruction. Une dernière raison est que la mémoire virtuelle n'est pas gérée de la même manière entre les processeurs 16, 32 et 64 bits. Mais nous ne pouvons en parler à ce stade du cours. Mais là encore, cela impacte le jeu d'instruction et le modèle mémoire du processeur, bien que d'une manière assez limitée. Cela revient à ajouter des registres, en supprimer d'autres, modifier l'interprétation de registres existants, etc. Ces changements se sont fait progressivement, ce qui fait que les jeux d'instruction 8/16/32/64 bits se ressemblent beaucoup, mais au point d'être parfaitement compatible. Exécutez un code prévu pour un CPU 16 bits sur un CPU 32 bits, vous aurez des problèmes. Pour éviter cela, les processeurs x86 supportent les différents modes d’instruction existants, par souci de compatibilité. Il est possible de configurer un processeur x86 en mode 16 bits, pour lui faire exécuter du code 16 bits directement, sans interprétation ou émulation d'instruction via exceptions matérielles. Ou encore, on peut mettre un CPU 64 bits en mode 32 bits, pour qu'il exécute du code 32 bits. Les modes en question sont : * le mode réel en 8 et 16 bits ; * le mode protégé 16 bit et sa variante 32 bits ; * le mode long en 64 bits, avec deux sous-modes de compatibilité pour exécuter du code 16 et 32 bits. Si l'ordinateur dispose d'un BIOS, il est démarré en mode réel, puis il passe en mode long pour laisser la main à un système d'exploitation 64 bits. Le mode protégé n'est pas utilisé, sauf éventuellement pour des besoins d'émulation. Si l'ordinateur dispose d'un UEFI, le processeur est lancé en mode long et y reste, que ce soit pour l'UEFI ou le système d'exploitation ou autre. Les modes réels et protégés ne sont utilisés que quand il faut émuler du vieux code. Il existe d'autres modes, comme le ''System Management Mode'' qui laisse la main au BIOS/UEFI, et un mode ''Virtual 8086'' qu'on ne peut pas expliquer à ce moment. Les deux seront cependant détaillés dans quelques chapitres. Le ''System Management Mode'' sera abordé dans le chapitre sur les interruptions, le mode ''Virtual 8086'' sera abordé dans le chapitre sur la virtualisation accélérée en matériel. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|Modes supportés par les CPU x86.]] Les processeurs ARM sont aussi dans ce cas. Leur jeu d'instruction de base a beaucoup évolué dans le temps. L'ARM de base était un jeu d'instruction 32 bits, mais une variante 64 bits a vu le jour par la suite. Comme pour les CPU x86, les CPU ARM 64 bits supportent deux modes d'exécution : un 32 bits et un 64 bits, là encore pour des questions de compatibilité. ===Le jeu d'instruction z-architecture d'IBM=== Les processeurs des ''mainframes'' IBM sont dans un cas similaire à celui du x86, mais avec un bagage encore plus long d'une décennies. Ses premiers ''mainframes'' étaient le System/360 (1964), le System/370 (1970) et ses dérivés, et le Systems Architecture/390 (ESA/390, 1990). Ils utilisaient un jeu d'instruction propriétaire, qui a été étendu progressivement le temps, pour passer d'un adressage de 24 bits sur le 360 à 31 bits pour le 390. Et leur jeu d'instruction a servit de base pour un jeu d'instruction 64 bits, appelé '''z-architecture'''. Le ''z-architecture'' est rétrocompatible les jeux d'instructions de ces ''mainframes''. Il est donc capable de fonctionner en mode 24 bits, 31 bits et 64 bits. Un peu comme les jeux d'instructions x86, sauf que c'est la taille des adresses qui change, pas la taille des données. Les ''mainframes'' géraient des données de 32 bits à la base, seules les adresses étaient plus courtes. La compatibilité était donc totalement différente. De nos jours, adresses et données ont été étendues à 64 bits. Le 360 intégrait 16 registres généraux de 32 bits, qui ont été conservés sur tous les processeurs suivants. ===Les jeux d'instruction compacts des processeurs RISC=== Quelques CPU RISC 32 bits supportaient un '''jeu d'instruction compact''', en plus de leur d'instruction classique. Il s'agissait d'un second jeu d’instruction avec une densité de code excellente, qui avait pour but de diminuer la taille des programmes. La technique a été introduite sur des CPU 32 bits, et le jeu d'instruction utilisait des instructions codées sur 16 bits. L'exemple type est celui des Les processeurs ARM, qui incorporent un jeu d'instruction compact appelé ''thumb'', que nous allons détailler dans ce qui suit. Le mode ''thumb'' était présent sur les processeurs ARM 32 bits, mais il a été conservé lors du passage au 64 bits. Le ''thumb'' encode les instructions sur 16 bits, soit deux fois moins que l'ARM 32 bits normal. Il ne s'agissait pas d'un jeu d'instruction totalement séparé de l'ARM normal, mais d'un sous-ensemble de l'ARM normal, utilisant un encodage différent. Concrètement, toute instruction ''thumb'' 16 bits a un équivalent en ARM 32 bits. Par contre, la réciproque n'est pas vraie. Et l'encodage des deux instructions n'est pas le même, ce qui est évident vu que les instructions ''thumb'' sont encodées sur 16 bits et les équivalentes sur 32. Les instructions ''thumb'' ont des limitations que les instructions ARM 32 bits n'ont pas. Par exemple, les instructions ''thumb'' ont un accès restreint aux registres de status et du co-processeur. De plus, la majorité des instructions ''thumb'' n'ont accès qu'à la moitié des registres du processeur. Les instructions arithmétiques et les décalages sont séparés en mode ''thumb'', alors que chaque instructions arithmétiques peut ajouter un décalage à une opérande en mode ARM 32 bits. Les instructions pour le co-processeur ne sont pas disponibles en ''thumb''. Autre exemple : la gestion des instructions à prédicats. L'ARM 32 bits normal utilise la prédication totale, à savoir que toutes les instructions s'exécutent ou non selon la valeur d'un résultat de comparaison, stocké dans un registre de prédicat. Toute instruction encode donc quelques bits pour choisir ce registre. Cela est supprimé avec ''thumb''. A part quelques branchements, les instructions ne sont pas prédicatées et s'exécutent inconditionnellement. Les deux jeux d'instruction ont un mode d'exécution chacun, ce qui veut dire que le processeur est configuré pour fonctionner en mode ''thumb'' ou en mode ARM 32 bits. En clair, on ne peut pas mélanger les deux jeu d'instructions en même temps. La raison est qu'une même suite de bit correspondait soit à une instruction ARM normale, soit à deux instructions thumbs. Pour faire la différence, il faut préciser dans quel mode d'exécution le CPU est : mode ARM ou mode ''thumb''. Le processeur interprète les instructions correctement en tenant compte de ce mode. Niveau implémentation, la gestion des instructions ''thumb'' est assez simple : le processeur convertit les instructions ''thumb'' en leur équivalent ARM 32 bits, juste après les avoir chargées. Une seconde version du jeu d'instruction ''thumb'' a vu le jour, sur les processeurs ARMv6T2, avec ''thumb-2''. Elle avait pour particularité d'utiliser des instructions de taille variable. Rien d'aussi développé que ce qu'on a sur les CPU CISC, comme les CPU x86. Les instructions ARM ''thumb-2'' faisaient soit 16 bits, soit 32 bits. Concrètement, les instructions 16 bits de ''thumb-1'' étaient reprises à l'identique, ''thumb-2'' se contentait d'ajouter des instructions en plus codées sur 32 bits. Les instructions ajoutées étaient des instruction mémoire, des instructions de configuration du CPU, des instructions de manipulation de bit, et quelques autres. Un ajout particulièrement notable est l'ajout d'une instruction IT, qui active l’exécution conditionnelle des instructions suivantes. L'instruction teste une condition et calcule un prédicat, qui est ensuite utilisé pour décider de l'exécution des instructions suivantes. L'exécution conditionnelle marche jusqu'à 4 instructions consécutives. Le nombre exact est configurable et va de 1 à 4 instructions consécutives après l'instruction IT. La condition est configurable pour les 1 à 4 instructions : chaque instruction peut s'exécuter si la condition est vraie, ou au contraire fausse. Un masque de 4 bits décide de quelle condition utiliser pour chaque instruction. Il y a un bit par instruction, qui vaut 1 si la condition doit être vérifiée, 0 si on veut la condition inverse. Il faut noter que quelques processeurs ARM implémentent uniquement le jeu d'instruction ''thumb-1/2'', pas l'ARM 32 bits normal. Ce sont des processeurs faible performance, à destination de systèmes embarqués, qui ont besoin d'une densité de code correcte, comme les CPU ARM Cortex série M. : Il est arrivé que certains CPU ARM en supportent trois : le jeu d'instruction ARM 32 bits classique, le mode ''Thumb'' qu'on décrira plus bas, et une extension nommée Jazelle JBX qui accélérait les programmes écrits en Java (elle exécutait du ''bytecode'' JAVA sur le processeur, nous détaillerons cela dans un autre chapitre). Les CPU ARM n'étaient pas les seuls à utiliser la même technique. Les CPU SuperH d'Hitachi ont été les premiers à implémenter cette technique, avant ARM. Lors du passage de 16 à 32 bits, ces CPU Hitachi ont ajouté un jeu d'instruction de 16 bits à leurs CPU 32 bits, très similaire au jeu d'instruction 16 bits d'avant. Et c'est ce jeu d'instruction compact qui a servi d'inspiration à ARM. Les CPU MIPS ont tenté la même approche, avec l'inclusion du jeu d’instruction MIPS16e, de 16 bits lui aussi. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=L'encodage des instructions | prevText=L'encodage des instructions | next=La pile d'appel et les fonctions | nextText=La pile d'appel et les fonctions }} </noinclude> 8vqkgx5wozbjobkvarkeamlxbppizb2 774182 774181 2026-10-04T23:21:40Z Mewtow 31375 /* La compatibilité entre 32 et 64 bits */ 774182 wikitext text/x-wiki Les instructions d'un processeur dépendent fortement du processeur utilisé. Le '''jeu d'instructions''' d'un processeur définit la liste des instructions supportées, ainsi que la manière dont elles sont encodées en mémoire. Le jeu d'instruction des PC actuels est le x86, un jeu d'instructions particulièrement ancien, apparu en 1978. Les macintoshs utilisent un jeu d'instruction différent : le PowerPC. Mais les architectures x86 et Power PC ne sont pas les seules au monde : il existe d'autres types d'architectures qui sont très utilisées dans le monde de l’informatique embarquée et dans tout ce qui est tablettes et téléphones portables derniers cris. On peut citer notamment les architectures ARM, MIPS et SPARC. Pour résumer, il existe différents jeux d'instructions, que l'on peut classer suivant divers critères. ==Les jeux d'instruction RISC vs CISC== Dans le chapitre précédent, nous avons vu comment sont encodées les instructions, ainsi que de nombreux paramètres : les modes d'adressage supportés, leur encodage, la taille des instructions, l'encodage de l'opcode, etc. Et tous ces paramètres sont suffisant pour parler des processeurs RISC et CISC. Il s'agit d'une classification qui sépare les processeurs en deux catégories : * les '''RISC''' (''reduced instruction set computer''), au jeu d'instruction simple ; * et les '''CISC''' (''complex instruction set computer''), qui ont un jeu d'instruction étoffé. Reste à expliquer ce que l'on veut dire quand on parler de jeu d'instruction "simple" ou "complexe". Et la différence n'est pas simple. Surtout que le terme CISC regroupe, comme on le verra, des architectures bien différentes. ===Les différences entre CISC et RISC=== La différence la plus intuitive est le nombre d'instructions supporté. Les processeurs CISC supportent beaucoup d'instructions, , alors que les processeurs RISC se contentent d'un petit nombre d'instructions assez simples. Un autre critère, très lié au précédent, est la complexité des instructions en question. Les processeurs CISC incorporent souvent des instructions complexes, comme le calcul de la division, de la racine carrée, de l'exponentielle, des puissances, des fonctions trigonométriques. Plus fréquent, on trouvait des instructions de contrôle élaborées pour gérer les appels de fonction, simplifier l'implémentation des boucles, etc. La différence principale fait la distinction entre les architectures LOAD-STORE (RISC) et celles qui ne le sont pas (CISC). Les processeurs RISC sont des '''architectures LOAD-STORE'''. Sur celles-ci, seules les instructions d'accès mémoire peuvent lire ou écrire en mémoire. Les autres instructions ne peuvent accéder qu'aux registres : elles lisent leurs opérandes dans les registres et enregistrent leur résultat dans les registres. En conséquence, les instructions de calcul ne peuvent prendre que des noms de registres ou des constantes comme opérandes, via les modes d'adressage immédiat et à registre. Pour le dire autrement, elles ne gèrent que les instructions reg-reg et cst-reg, pas les instructions ''load-op''. La distinction se fait uniquement au niveau des instructions de calcul/branchement. Les premiers processeurs RISC se contentaient de deux instructions d'accès mémoire : LOADq et STORE. Elles ont d'ailleurs donné leur nom à ce type d'architecture. Mais dans les faits, les processeurs RISC modernes peuvent gérer d'autres instructions d'accès à la mémoire. Par exemple, les processeurs ARM7 supportent des instructions de copie mémoire-mémoire nommées CPYP, CPYM et CPYE. Et ce n'est pas incompatible avec un processeur RISC, ni avec une architecture LOAD-STORE. De telles instructions ont beau être assez complexes, ça reste des instructions d'accès mémoire. Même si elles font plusieurs accès mémoire par instruction, c'est comme si on avait fusionné un LOAD suivi d'un STORE en une seule instruction. Les architectures LOAD-STORE sont les seules à avoir une stricte séparation entre instructions d'accès mémoire et instructions de calcul. À l'opposé, les processeurs CISC ne sont pas des architectures LOAD-STORE, leurs instructions de calcul/branchement peuvent effectuer des accès mémoire, soit pour aller chercher leurs opérandes en RAM, soit pour écrire un résultat. Elles peuvent même en faire plusieurs si plusieurs opérandes sont en mémoire, encore que ce ne soit pas possible sur tous les jeux d'instructions. Au minimum, les processeurs CISC gèrent les instructions ''load-op''. Quelques processeurs CISC anciens supportent tous les modes d'adressage possibles pour les opérandes et le résultat, qui peuvent tous trois être lus/écrits dans les registres ou la RAM. Les deux propriétés précédentes, à savoir un grand nombre d'instruction et des modes d'adressages complexes, se marient bien avec des instructions de longueur variable. Les instructions d'un processeur CISC sont de taille variable pour diverses raisons, mais la variété des modes d'adressage y est pour beaucoup. Quand une même instruction peut incorporer soit deux noms de registres, soit deux adresses, soit un nom de registre et une adresse, sa taille ne sera pas la même dans les trois cas. Les processeurs RISC ne sont pas concernés par ce problème. Les instructions de calcul n'utilisent que deux-trois modes d'adressages simples, qui demandent d'encoder des registres ou des constantes immédiates de petite taille. Les autres instructions se débrouillent avec des adresses et éventuellement un nom de registre. Le tout peut être encodé sur 32 ou 64 bits sans problèmes. {|class="wikitable" |+ Différences CISC/RISC |- ! Propriété !! CISC !! RISC |- ! scope="col" | Instructions | * Nombre d'instructions élevé, parfois plus d'une centaine. * Parfois, présence d'instructions complexes (fonctions trigonométriques, gestion de texte, autres). * Supportent des types de données complexes : texte, listes chainées, etc. * Instructions de taille variable. | * Faible nombre d'instructions, moins d'une centaine. * Pas d'instruction complexes. * Types supportés limités aux entiers (adresses comprises) et flottants. * Instructions de taille fixe. |- ! scope="col" | Modes d'adressage | * Support des instructions ''load op'', systématique * Souvent, support d'instructions multi-accès (plusieurs accès mémoires par instruction). | * Architecture LOAD-STORE. * Un seul accès mémoire par instruction maximum |- ! scope="col" | Registres | * Peu de registres : rarement plus de 16 registres entiers. | * Beaucoup de registres, souvent plus de 32. |} Les jeux d'instruction CISC sont moins nombreux que les jeu d'instructions RISC. Les jeux d'instructions RISC les plus connu sont ARM, RISC-V, MIPS, Power et PowerPC, PA-RISC, et de nombreux autres. Pour le CISC, le x86 et le z/ architecture d'IBm sont les deux jeux d'instruction restants. A vrai dire, si on omet les anciens jeux d'instruction CISC utilisés sur les anciens ''mainframes'', ce sont les deux seuls utilisés à l'heure actuelle. : Pour ceux qui veulent en savoir plus sur le jeu d'instruction et les processeurs, PA-RISC, ce site vous donnera toutes les informations possibles : [https://www.openpa.net/ Open PA-RISC]. ===Les performances relatives des CISC/RISC=== Pour comprendre quels sont les avantages et désavantages des processeurs CISC comparé au RISC, nous allons étudier leur performance et leur ''code density''. Pour ce qui est de la performance, les choses ne sont pas clairement tranchées. Pour comprendre pourquoi, nous allons partir d'une équation déjà abordée dans un chapitre antérieur, qui donne le temps que met un programme à s'exécuter. Le temps que met un programme pour s’exécuter est le produit : * du nombre moyen d'instructions exécutées par le programme ; * de la durée moyenne d'une instruction, en seconde. : <math>T = N \times T_\text{instruction}</math>, avec N le nombre moyen d'instruction du programme et <math>T_\text{instruction}</math> la durée moyenne d'une instruction. Le nombre moyen d'instructions exécuté par un programme s'appelle l'''Instruction path length'', ou encore '''longueur du chemin d'instruction''' en français. Si on utilise le nombre ''moyen'' d’instructions, c'est car il n'est pas forcément le même d'une exécution à l'autre, notamment en présence de conditions ou de boucles. Le processeurs CISC intègrent des instructions complexes, que les processeurs RISC doivent émuler à partir d'une suite d'opérations plus simples. Par exemple, les instructions ''load-op'' sont émulées avec une instruction LOAD suivie d'une instruction de calcul. En clair, les processeurs CISC ont un avantage pour le paramètre N, la longueur du chemin d'instruction. Par contre, il n'est pas garantit que les instructions complexes soient aussi rapides que la suite d'instruction RISC équivalente, car les processeurs RISC ont un avantage pour le terme <math>T_\text{instruction}</math>. Voyons pourquoi. Les processeurs CISC gèrent un grand nombre d'instructions et de modes d'adressage, ce qui les rend plus gourmands en transistors. Non pas qu'il faille ajouter plus de circuits de calcul, les CISC se débrouillent bien avec les circuits usuels pour les 4 opérations basiques. Le vrai problème est le support des nombreux modes d'adressage, des instructions de taille variable, et le grand nombre de variantes de la même opération. En conséquence, les circuits de contrôle du processeur sont plus complexes et utilisent beaucoup de transistors. Le budget en transistors utilisé pour les circuits de contrôle ne sont pas disponibles pour autre chose, comme de la mémoire cache ou des registres. De plus, cela a tendance à limiter la fréquence du processeur. Les processeurs RISC sont eux plus économes, ils ont moins d'instructions, moins de circuits de contrôle. Ils peuvent utiliser plus de transistors pour autre chose, souvent de la mémoire cache ou des registres. Les processeurs RISC peuvent se permettre d'utiliser beaucoup de registres, ils ont le budget en transistor pour. Et c'est sans compter que la simplicité des circuits de contrôle et des connexions intra-processeur (le bus interne au CPU qu'on verra dans quelques chapitres) fait qu'on peut faire fonctionner le processeur à plus haute fréquence. Si les registres sont plus nombreux sur les architectures RISC, ce n'est pas qu'une question de budget en transistors. Une autre raison est que les architectures LOAD-STORE ont besoin de plus de registres pour faire le même travail que les architectures à registres, du fait de l'absence d'instructions ''load-op''. La ''register pressure'' est donc légèrement plus importante, ce qui est compensée en ajoutant des registres. ===La densité de code relative entre RISC et CISC=== Si la performance ne donne pas de vainqueur clair entre CISC et RISC, il y a cependant un second critère sur lequel la victoire est bien plus nette. Il s'agit de la '''densité de code''', à savoir la taille des programmes mesurée en octets. De nos jours, la taille des programmes n'est pas importante au point d'être un problème, ce n'est pas ca qui prendra plusieurs gibioctets de mémoire. Par contre, au tout début de l'informatique, la mémoire était chère et limitée, avoir des programmes petits était un avantage clair. La taille d'un programme dépend de deux choses : le nombre d'instructions et leur taille. Et ces deux paramètres sont influencés par le caractère CISC/RISC. Comme dit plus haut, les architectures CISC ont un avantage pour le nombre d'instruction N, du fait de la présence d'instructions ''load-op'' et d'instructions complexes. Mais elles ont aussi un avantage pour la taille des instructions. Les instructions des processeurs CISC sont de taille variable, là où celles des processeurs RISC sont de taille fixe. Les instructions CISC ont une taille allant de très courtes à très longues, celles des RISC sont de taille moyenne. Mais le fait d'avoir des instructions très courtes l'emporte sur les processeurs CISC. Une minorité de processeurs RISC arrivent à obtenir une densité de code proche de celle des processeurs CISC. Pour cela, ils ont classes d'instructions de taille différente. Un exemple est jeu d'instruction RISC-V, où il existe des instructions "normales" de 32 bits et des instructions "compressées" de 16 bits. Le processeur charge un mot de 32 bits, ce qui fait qu'il peut lire entre une et deux instructions à la fois. Au tout début de l'instruction, un bit est mis à 0 ou 1 selon que l'instruction soit longue ou courte. Le reste de l'instruction varie suivant sa longueur. De telles instructions de taille quasi-variables permettent de grandement gagner en densité de code. Il faut noter que sur les processeurs modernes, avoir une bonne densité de code a un impact secondaire sur les performances. En effet, les processeurs modernes ont une mémoire cache qui sert à la fois pour les données, mais aussi pour les instructions chargées depuis la mémoire. Les instructions sont conservées dans le cache après leur exécution, pour une exécution ultérieure (ce qui implique une boucle). Si une instruction est dans le cache, elle est lue directement depuis le cache, sans passer par un long accès en mémoire RAM. Les processeurs modernes ont notamment un petit cache d'instruction, spécialement dédié aux instructions. Plus la densité de code est bonne, plus le cache d'instruction pourra contenir d'instructions, plus il sera efficace, plus il filtrera d'accès mémoire. ===Un petit historique de la distinction entre processeurs CISC et RISC=== Les jeux d'instructions CISC sont les plus anciens et étaient à la mode jusqu'à la fin des années 1980. À cette époque, on programmait rarement avec des langages de haut niveau et beaucoup de programmeurs codaient en assembleur. Avoir un jeu d'instruction complexe, avec des instructions de "haut niveau" facilitait la vie des programmeurs. Et leur meilleure densité de code était un sérieux avantage, car la mémoire était rare et chère. Au cours des années 70-80, la mémoire est devenue moins chère et les langages de haut niveau sont devenus la norme. Le contexte technologique ayant changé, les processeurs CISC étaient à réévaluer. Est-ce que les instructions complexes des processeurs CISC sont vraiment utiles ? Pour le programmeur qui écrit ses programmes en assembleur, elles le sont. Mais avec les langages de haut niveau, la réponse dépend de l'efficacité des compilateurs. Des analyses assez anciennes, effectuées par IBM, DEC et quelques laboratoires de recherche, ont montré que les compilateurs utilisaient les instructions ''load-op'' correctement, mais n'utilisaient pas les instructions complexes. Les analyses plus récentes fournissent la même conclusion. La faible densité de code n'était pas un problème, vu que les mémoires ont une capacité suffisante, encore que ce soit à nuancer. L'idée de créer des processeurs RISC commença à germer. Mais les processeurs RISC durent attendre un peu avant de percer. Par exemple, l'IBM 801, un processeur au jeu d'instruction très sobre, fût un véritable échec commercial. C'est dans les années 1980 que les processeurs possédant un jeu d'instruction simple devinrent à la mode. Cette année-là, un scientifique de l'université de Berkeley décida de créer un processeur possédant un jeu d'instruction contenant seulement un nombre réduit d'instructions simples, possédant une architecture particulière. Ce processeur était assez novateur et incorporait de nombreuses améliorations qu'on retrouve encore dans nos processeurs haute performances actuels, ce qui fit son succès : les processeurs RISC étaient nés. Les CISC et les RISC ont chacun des avantages et des inconvénients, qui rendent le RISC/CISC adapté ou pas selon la situation. Par exemple, on mettra souvent un processeur RISC dans un système embarqué, devant consommer très peu et être peu cher. Mais de nos jours, la performance d'un processeur dépend assez peu du fait que le processeur soit un RISC ou un CISC. Les processeurs modernes disposent de tellement de transistors qu'implémenter des instructions complexes a un impact négligeable. Pour donner une référence, le support des instructions complexes sur les processeurs x86 est estimé à 2 à 3% des transistors de la puce, alors que 50% du budget en transistor des processeurs modernes part dans le cache. Les processeurs actuels sont de plus en plus difficiles à ranger dans des catégories précises. Les processeurs actuels sont conçus d'une façon plus pragmatique : au lieu de respecter à la lettre les principes du RISC et du CISC, on préfère intégrer les instructions qui fonctionnent, peu importe qu'elles viennent de processeurs purement RISC ou CISC. Par exemple, les processeurs ARM récents ont intégré des instructions de copie mémoire, qui sont assez ''borderline'' et sont plutôt attendues sur les processeurs CISC. En parallèle des architectures CISC et RISC, d'autres classes de jeux d'instructions sont apparus. Nous verrons beaucoup de jeux d'instructions dans la suite du cours. Entre les architectures à capacité, les processeurs VLIW, les architectures ''dataflow'', les processeurs SIMD, les ''Digital Signal Processor'', les ''transport-triggered architectures'', les architectures associatives, les architectures neuromorphiques et les achitectures systoliques, il y aura de quoi faire. La plupart de ces jeux d'instructions sont implantés dans des processeurs spécialisés, qu'on fabrique pour une utilisation particulière. Malheureusement, nous ne pouvons pas en parler pour le moment. La suite de ce chapitre va se focaliser sur des jeu d'instruction proches du RISC ou du CISC. ==Les processeurs avec plusieurs jeu d'instruction== Il faut signaler que certains processeur supportent plusieurs jeu d'instruction à la fois. Les processeurs x86 des PC sont dans ce cas, mais aussi les processeurs ARM utilisés partout ailleurs. ===Le x86 : le passage de CPU 8 bits à 16, puis 32 et enfin 64 bits=== Le cas des processeurs x86 est assez simple à comprendre. Ils ont beaucoup évolués dans le temps, commençant comme des processeurs 8 bits, puis 16 bits, suivis par des 32 bits et enfin des processeurs 64 bits. Et à chaque transition, le jeu d'instruction a évolué. Le x86 8 bit n'est pas le même que le x86 16 bits, qui est lui-même différent du x86 32 bits, etc. La raison est que pour étendre les adresses, il fallait modifier l'encodage des instructions. Et quitte à modifier l'encodage des instructions, autant en rajouter d'autres, et supprimer des instructions devenus inutiles. Une autre raison est qu'Intel et AMD ont rajouté des registres lors du passage au 32 bits, et idem lors du passage au 64 bits. Le jeu d'instruction x86 16 bits avait trop peu de registres pour des besoins actuels, ce qui fait qu'Intel et AMD ont décidé d'en rajouter. Et vu que rajouter des registres impactait l'encodage des instructions, cela ne pouvait se faire qu'à l'occasion d'un changement de jeu d'instruction. Une dernière raison est que la mémoire virtuelle n'est pas gérée de la même manière entre les processeurs 16, 32 et 64 bits. Mais nous ne pouvons en parler à ce stade du cours. Mais là encore, cela impacte le jeu d'instruction et le modèle mémoire du processeur, bien que d'une manière assez limitée. Cela revient à ajouter des registres, en supprimer d'autres, modifier l'interprétation de registres existants, etc. Ces changements se sont fait progressivement, ce qui fait que les jeux d'instruction 8/16/32/64 bits se ressemblent beaucoup, mais au point d'être parfaitement compatible. Exécutez un code prévu pour un CPU 16 bits sur un CPU 32 bits, vous aurez des problèmes. Pour éviter cela, les processeurs x86 supportent les différents modes d’instruction existants, par souci de compatibilité. Il est possible de configurer un processeur x86 en mode 16 bits, pour lui faire exécuter du code 16 bits directement, sans interprétation ou émulation d'instruction via exceptions matérielles. Ou encore, on peut mettre un CPU 64 bits en mode 32 bits, pour qu'il exécute du code 32 bits. Les modes en question sont : * le mode réel en 8 et 16 bits ; * le mode protégé 16 bit et sa variante 32 bits ; * le mode long en 64 bits, avec deux sous-modes de compatibilité pour exécuter du code 16 et 32 bits. Si l'ordinateur dispose d'un BIOS, il est démarré en mode réel, puis il passe en mode long pour laisser la main à un système d'exploitation 64 bits. Le mode protégé n'est pas utilisé, sauf éventuellement pour des besoins d'émulation. Si l'ordinateur dispose d'un UEFI, le processeur est lancé en mode long et y reste, que ce soit pour l'UEFI ou le système d'exploitation ou autre. Les modes réels et protégés ne sont utilisés que quand il faut émuler du vieux code. Il existe d'autres modes, comme le ''System Management Mode'' qui laisse la main au BIOS/UEFI, et un mode ''Virtual 8086'' qu'on ne peut pas expliquer à ce moment. Les deux seront cependant détaillés dans quelques chapitres. Le ''System Management Mode'' sera abordé dans le chapitre sur les interruptions, le mode ''Virtual 8086'' sera abordé dans le chapitre sur la virtualisation accélérée en matériel. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|Modes supportés par les CPU x86.]] Les processeurs ARM sont aussi dans ce cas. Leur jeu d'instruction de base a beaucoup évolué dans le temps. L'ARM de base était un jeu d'instruction 32 bits, mais une variante 64 bits a vu le jour par la suite. Comme pour les CPU x86, les CPU ARM 64 bits supportent deux modes d'exécution : un 32 bits et un 64 bits, là encore pour des questions de compatibilité. ===Le jeu d'instruction z-architecture d'IBM=== Les processeurs des ''mainframes'' IBM sont dans un cas similaire à celui du x86, mais avec un bagage encore plus long d'une décennies. Ses premiers ''mainframes'' étaient le System/360 (1964), le System/370 (1970) et ses dérivés, et le Systems Architecture/390 (ESA/390, 1990). Ils utilisaient un jeu d'instruction propriétaire, qui a été étendu progressivement le temps, pour passer d'un adressage de 24 bits sur le 360 à 31 bits pour le 390. Et leur jeu d'instruction a servit de base pour un jeu d'instruction 64 bits, appelé '''z-architecture'''. Le ''z-architecture'' est rétrocompatible les jeux d'instructions de ces ''mainframes''. Il est donc capable de fonctionner en mode 24 bits, 31 bits et 64 bits. Un peu comme les jeux d'instructions x86, sauf que c'est la taille des adresses qui change, pas la taille des données. Les ''mainframes'' géraient des données de 32 bits à la base, seules les adresses étaient plus courtes. La compatibilité était donc totalement différente. De nos jours, adresses et données ont été étendues à 64 bits. Le 360 intégrait 16 registres généraux de 32 bits, qui ont été conservés sur tous les processeurs suivants. ===Les jeux d'instruction compacts des processeurs RISC=== Quelques CPU RISC 32 bits supportaient un '''jeu d'instruction compact''', en plus de leur d'instruction classique. Il s'agissait d'un second jeu d’instruction avec une densité de code excellente, qui avait pour but de diminuer la taille des programmes. La technique a été introduite sur des CPU 32 bits, et le jeu d'instruction utilisait des instructions codées sur 16 bits. L'exemple type est celui des Les processeurs ARM, qui incorporent un jeu d'instruction compact appelé ''thumb'', que nous allons détailler dans ce qui suit. Le mode ''thumb'' était présent sur les processeurs ARM 32 bits, mais il a été conservé lors du passage au 64 bits. Le ''thumb'' encode les instructions sur 16 bits, soit deux fois moins que l'ARM 32 bits normal. Il ne s'agissait pas d'un jeu d'instruction totalement séparé de l'ARM normal, mais d'un sous-ensemble de l'ARM normal, utilisant un encodage différent. Concrètement, toute instruction ''thumb'' 16 bits a un équivalent en ARM 32 bits. Par contre, la réciproque n'est pas vraie. Et l'encodage des deux instructions n'est pas le même, ce qui est évident vu que les instructions ''thumb'' sont encodées sur 16 bits et les équivalentes sur 32. Les instructions ''thumb'' ont des limitations que les instructions ARM 32 bits n'ont pas. Par exemple, les instructions ''thumb'' ont un accès restreint aux registres de status et du co-processeur. De plus, la majorité des instructions ''thumb'' n'ont accès qu'à la moitié des registres du processeur. Les instructions arithmétiques et les décalages sont séparés en mode ''thumb'', alors que chaque instructions arithmétiques peut ajouter un décalage à une opérande en mode ARM 32 bits. Les instructions pour le co-processeur ne sont pas disponibles en ''thumb''. Autre exemple : la gestion des instructions à prédicats. L'ARM 32 bits normal utilise la prédication totale, à savoir que toutes les instructions s'exécutent ou non selon la valeur d'un résultat de comparaison, stocké dans un registre de prédicat. Toute instruction encode donc quelques bits pour choisir ce registre. Cela est supprimé avec ''thumb''. A part quelques branchements, les instructions ne sont pas prédicatées et s'exécutent inconditionnellement. Les deux jeux d'instruction ont un mode d'exécution chacun, ce qui veut dire que le processeur est configuré pour fonctionner en mode ''thumb'' ou en mode ARM 32 bits. En clair, on ne peut pas mélanger les deux jeu d'instructions en même temps. La raison est qu'une même suite de bit correspondait soit à une instruction ARM normale, soit à deux instructions thumbs. Pour faire la différence, il faut préciser dans quel mode d'exécution le CPU est : mode ARM ou mode ''thumb''. Le processeur interprète les instructions correctement en tenant compte de ce mode. Niveau implémentation, la gestion des instructions ''thumb'' est assez simple : le processeur convertit les instructions ''thumb'' en leur équivalent ARM 32 bits, juste après les avoir chargées. Une seconde version du jeu d'instruction ''thumb'' a vu le jour, sur les processeurs ARMv6T2, avec ''thumb-2''. Elle avait pour particularité d'utiliser des instructions de taille variable. Rien d'aussi développé que ce qu'on a sur les CPU CISC, comme les CPU x86. Les instructions ARM ''thumb-2'' faisaient soit 16 bits, soit 32 bits. Concrètement, les instructions 16 bits de ''thumb-1'' étaient reprises à l'identique, ''thumb-2'' se contentait d'ajouter des instructions en plus codées sur 32 bits. Les instructions ajoutées étaient des instruction mémoire, des instructions de configuration du CPU, des instructions de manipulation de bit, et quelques autres. Un ajout particulièrement notable est l'ajout d'une instruction IT, qui active l’exécution conditionnelle des instructions suivantes. L'instruction teste une condition et calcule un prédicat, qui est ensuite utilisé pour décider de l'exécution des instructions suivantes. L'exécution conditionnelle marche jusqu'à 4 instructions consécutives. Le nombre exact est configurable et va de 1 à 4 instructions consécutives après l'instruction IT. La condition est configurable pour les 1 à 4 instructions : chaque instruction peut s'exécuter si la condition est vraie, ou au contraire fausse. Un masque de 4 bits décide de quelle condition utiliser pour chaque instruction. Il y a un bit par instruction, qui vaut 1 si la condition doit être vérifiée, 0 si on veut la condition inverse. Il faut noter que quelques processeurs ARM implémentent uniquement le jeu d'instruction ''thumb-1/2'', pas l'ARM 32 bits normal. Ce sont des processeurs faible performance, à destination de systèmes embarqués, qui ont besoin d'une densité de code correcte, comme les CPU ARM Cortex série M. : Il est arrivé que certains CPU ARM en supportent trois : le jeu d'instruction ARM 32 bits classique, le mode ''Thumb'' qu'on décrira plus bas, et une extension nommée Jazelle JBX qui accélérait les programmes écrits en Java (elle exécutait du ''bytecode'' JAVA sur le processeur, nous détaillerons cela dans un autre chapitre). Les CPU ARM n'étaient pas les seuls à utiliser la même technique. Les CPU SuperH d'Hitachi ont été les premiers à implémenter cette technique, avant ARM. Lors du passage de 16 à 32 bits, ces CPU Hitachi ont ajouté un jeu d'instruction de 16 bits à leurs CPU 32 bits, très similaire au jeu d'instruction 16 bits d'avant. Et c'est ce jeu d'instruction compact qui a servi d'inspiration à ARM. Les CPU MIPS ont tenté la même approche, avec l'inclusion du jeu d’instruction MIPS16e, de 16 bits lui aussi. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=L'encodage des instructions | prevText=L'encodage des instructions | next=La pile d'appel et les fonctions | nextText=La pile d'appel et les fonctions }} </noinclude> e0xvf61iuk3tg47ncwhpjf6azdrqkj1 774183 774182 2026-10-04T23:32:58Z Mewtow 31375 /* Le jeu d'instruction z-architecture d'IBM */ 774183 wikitext text/x-wiki Les instructions d'un processeur dépendent fortement du processeur utilisé. Le '''jeu d'instructions''' d'un processeur définit la liste des instructions supportées, ainsi que la manière dont elles sont encodées en mémoire. Le jeu d'instruction des PC actuels est le x86, un jeu d'instructions particulièrement ancien, apparu en 1978. Les macintoshs utilisent un jeu d'instruction différent : le PowerPC. Mais les architectures x86 et Power PC ne sont pas les seules au monde : il existe d'autres types d'architectures qui sont très utilisées dans le monde de l’informatique embarquée et dans tout ce qui est tablettes et téléphones portables derniers cris. On peut citer notamment les architectures ARM, MIPS et SPARC. Pour résumer, il existe différents jeux d'instructions, que l'on peut classer suivant divers critères. ==Les jeux d'instruction RISC vs CISC== Dans le chapitre précédent, nous avons vu comment sont encodées les instructions, ainsi que de nombreux paramètres : les modes d'adressage supportés, leur encodage, la taille des instructions, l'encodage de l'opcode, etc. Et tous ces paramètres sont suffisant pour parler des processeurs RISC et CISC. Il s'agit d'une classification qui sépare les processeurs en deux catégories : * les '''RISC''' (''reduced instruction set computer''), au jeu d'instruction simple ; * et les '''CISC''' (''complex instruction set computer''), qui ont un jeu d'instruction étoffé. Reste à expliquer ce que l'on veut dire quand on parler de jeu d'instruction "simple" ou "complexe". Et la différence n'est pas simple. Surtout que le terme CISC regroupe, comme on le verra, des architectures bien différentes. ===Les différences entre CISC et RISC=== La différence la plus intuitive est le nombre d'instructions supporté. Les processeurs CISC supportent beaucoup d'instructions, , alors que les processeurs RISC se contentent d'un petit nombre d'instructions assez simples. Un autre critère, très lié au précédent, est la complexité des instructions en question. Les processeurs CISC incorporent souvent des instructions complexes, comme le calcul de la division, de la racine carrée, de l'exponentielle, des puissances, des fonctions trigonométriques. Plus fréquent, on trouvait des instructions de contrôle élaborées pour gérer les appels de fonction, simplifier l'implémentation des boucles, etc. La différence principale fait la distinction entre les architectures LOAD-STORE (RISC) et celles qui ne le sont pas (CISC). Les processeurs RISC sont des '''architectures LOAD-STORE'''. Sur celles-ci, seules les instructions d'accès mémoire peuvent lire ou écrire en mémoire. Les autres instructions ne peuvent accéder qu'aux registres : elles lisent leurs opérandes dans les registres et enregistrent leur résultat dans les registres. En conséquence, les instructions de calcul ne peuvent prendre que des noms de registres ou des constantes comme opérandes, via les modes d'adressage immédiat et à registre. Pour le dire autrement, elles ne gèrent que les instructions reg-reg et cst-reg, pas les instructions ''load-op''. La distinction se fait uniquement au niveau des instructions de calcul/branchement. Les premiers processeurs RISC se contentaient de deux instructions d'accès mémoire : LOADq et STORE. Elles ont d'ailleurs donné leur nom à ce type d'architecture. Mais dans les faits, les processeurs RISC modernes peuvent gérer d'autres instructions d'accès à la mémoire. Par exemple, les processeurs ARM7 supportent des instructions de copie mémoire-mémoire nommées CPYP, CPYM et CPYE. Et ce n'est pas incompatible avec un processeur RISC, ni avec une architecture LOAD-STORE. De telles instructions ont beau être assez complexes, ça reste des instructions d'accès mémoire. Même si elles font plusieurs accès mémoire par instruction, c'est comme si on avait fusionné un LOAD suivi d'un STORE en une seule instruction. Les architectures LOAD-STORE sont les seules à avoir une stricte séparation entre instructions d'accès mémoire et instructions de calcul. À l'opposé, les processeurs CISC ne sont pas des architectures LOAD-STORE, leurs instructions de calcul/branchement peuvent effectuer des accès mémoire, soit pour aller chercher leurs opérandes en RAM, soit pour écrire un résultat. Elles peuvent même en faire plusieurs si plusieurs opérandes sont en mémoire, encore que ce ne soit pas possible sur tous les jeux d'instructions. Au minimum, les processeurs CISC gèrent les instructions ''load-op''. Quelques processeurs CISC anciens supportent tous les modes d'adressage possibles pour les opérandes et le résultat, qui peuvent tous trois être lus/écrits dans les registres ou la RAM. Les deux propriétés précédentes, à savoir un grand nombre d'instruction et des modes d'adressages complexes, se marient bien avec des instructions de longueur variable. Les instructions d'un processeur CISC sont de taille variable pour diverses raisons, mais la variété des modes d'adressage y est pour beaucoup. Quand une même instruction peut incorporer soit deux noms de registres, soit deux adresses, soit un nom de registre et une adresse, sa taille ne sera pas la même dans les trois cas. Les processeurs RISC ne sont pas concernés par ce problème. Les instructions de calcul n'utilisent que deux-trois modes d'adressages simples, qui demandent d'encoder des registres ou des constantes immédiates de petite taille. Les autres instructions se débrouillent avec des adresses et éventuellement un nom de registre. Le tout peut être encodé sur 32 ou 64 bits sans problèmes. {|class="wikitable" |+ Différences CISC/RISC |- ! Propriété !! CISC !! RISC |- ! scope="col" | Instructions | * Nombre d'instructions élevé, parfois plus d'une centaine. * Parfois, présence d'instructions complexes (fonctions trigonométriques, gestion de texte, autres). * Supportent des types de données complexes : texte, listes chainées, etc. * Instructions de taille variable. | * Faible nombre d'instructions, moins d'une centaine. * Pas d'instruction complexes. * Types supportés limités aux entiers (adresses comprises) et flottants. * Instructions de taille fixe. |- ! scope="col" | Modes d'adressage | * Support des instructions ''load op'', systématique * Souvent, support d'instructions multi-accès (plusieurs accès mémoires par instruction). | * Architecture LOAD-STORE. * Un seul accès mémoire par instruction maximum |- ! scope="col" | Registres | * Peu de registres : rarement plus de 16 registres entiers. | * Beaucoup de registres, souvent plus de 32. |} Les jeux d'instruction CISC sont moins nombreux que les jeu d'instructions RISC. Les jeux d'instructions RISC les plus connu sont ARM, RISC-V, MIPS, Power et PowerPC, PA-RISC, et de nombreux autres. Pour le CISC, le x86 et le z/ architecture d'IBm sont les deux jeux d'instruction restants. A vrai dire, si on omet les anciens jeux d'instruction CISC utilisés sur les anciens ''mainframes'', ce sont les deux seuls utilisés à l'heure actuelle. : Pour ceux qui veulent en savoir plus sur le jeu d'instruction et les processeurs, PA-RISC, ce site vous donnera toutes les informations possibles : [https://www.openpa.net/ Open PA-RISC]. ===Les performances relatives des CISC/RISC=== Pour comprendre quels sont les avantages et désavantages des processeurs CISC comparé au RISC, nous allons étudier leur performance et leur ''code density''. Pour ce qui est de la performance, les choses ne sont pas clairement tranchées. Pour comprendre pourquoi, nous allons partir d'une équation déjà abordée dans un chapitre antérieur, qui donne le temps que met un programme à s'exécuter. Le temps que met un programme pour s’exécuter est le produit : * du nombre moyen d'instructions exécutées par le programme ; * de la durée moyenne d'une instruction, en seconde. : <math>T = N \times T_\text{instruction}</math>, avec N le nombre moyen d'instruction du programme et <math>T_\text{instruction}</math> la durée moyenne d'une instruction. Le nombre moyen d'instructions exécuté par un programme s'appelle l'''Instruction path length'', ou encore '''longueur du chemin d'instruction''' en français. Si on utilise le nombre ''moyen'' d’instructions, c'est car il n'est pas forcément le même d'une exécution à l'autre, notamment en présence de conditions ou de boucles. Le processeurs CISC intègrent des instructions complexes, que les processeurs RISC doivent émuler à partir d'une suite d'opérations plus simples. Par exemple, les instructions ''load-op'' sont émulées avec une instruction LOAD suivie d'une instruction de calcul. En clair, les processeurs CISC ont un avantage pour le paramètre N, la longueur du chemin d'instruction. Par contre, il n'est pas garantit que les instructions complexes soient aussi rapides que la suite d'instruction RISC équivalente, car les processeurs RISC ont un avantage pour le terme <math>T_\text{instruction}</math>. Voyons pourquoi. Les processeurs CISC gèrent un grand nombre d'instructions et de modes d'adressage, ce qui les rend plus gourmands en transistors. Non pas qu'il faille ajouter plus de circuits de calcul, les CISC se débrouillent bien avec les circuits usuels pour les 4 opérations basiques. Le vrai problème est le support des nombreux modes d'adressage, des instructions de taille variable, et le grand nombre de variantes de la même opération. En conséquence, les circuits de contrôle du processeur sont plus complexes et utilisent beaucoup de transistors. Le budget en transistors utilisé pour les circuits de contrôle ne sont pas disponibles pour autre chose, comme de la mémoire cache ou des registres. De plus, cela a tendance à limiter la fréquence du processeur. Les processeurs RISC sont eux plus économes, ils ont moins d'instructions, moins de circuits de contrôle. Ils peuvent utiliser plus de transistors pour autre chose, souvent de la mémoire cache ou des registres. Les processeurs RISC peuvent se permettre d'utiliser beaucoup de registres, ils ont le budget en transistor pour. Et c'est sans compter que la simplicité des circuits de contrôle et des connexions intra-processeur (le bus interne au CPU qu'on verra dans quelques chapitres) fait qu'on peut faire fonctionner le processeur à plus haute fréquence. Si les registres sont plus nombreux sur les architectures RISC, ce n'est pas qu'une question de budget en transistors. Une autre raison est que les architectures LOAD-STORE ont besoin de plus de registres pour faire le même travail que les architectures à registres, du fait de l'absence d'instructions ''load-op''. La ''register pressure'' est donc légèrement plus importante, ce qui est compensée en ajoutant des registres. ===La densité de code relative entre RISC et CISC=== Si la performance ne donne pas de vainqueur clair entre CISC et RISC, il y a cependant un second critère sur lequel la victoire est bien plus nette. Il s'agit de la '''densité de code''', à savoir la taille des programmes mesurée en octets. De nos jours, la taille des programmes n'est pas importante au point d'être un problème, ce n'est pas ca qui prendra plusieurs gibioctets de mémoire. Par contre, au tout début de l'informatique, la mémoire était chère et limitée, avoir des programmes petits était un avantage clair. La taille d'un programme dépend de deux choses : le nombre d'instructions et leur taille. Et ces deux paramètres sont influencés par le caractère CISC/RISC. Comme dit plus haut, les architectures CISC ont un avantage pour le nombre d'instruction N, du fait de la présence d'instructions ''load-op'' et d'instructions complexes. Mais elles ont aussi un avantage pour la taille des instructions. Les instructions des processeurs CISC sont de taille variable, là où celles des processeurs RISC sont de taille fixe. Les instructions CISC ont une taille allant de très courtes à très longues, celles des RISC sont de taille moyenne. Mais le fait d'avoir des instructions très courtes l'emporte sur les processeurs CISC. Une minorité de processeurs RISC arrivent à obtenir une densité de code proche de celle des processeurs CISC. Pour cela, ils ont classes d'instructions de taille différente. Un exemple est jeu d'instruction RISC-V, où il existe des instructions "normales" de 32 bits et des instructions "compressées" de 16 bits. Le processeur charge un mot de 32 bits, ce qui fait qu'il peut lire entre une et deux instructions à la fois. Au tout début de l'instruction, un bit est mis à 0 ou 1 selon que l'instruction soit longue ou courte. Le reste de l'instruction varie suivant sa longueur. De telles instructions de taille quasi-variables permettent de grandement gagner en densité de code. Il faut noter que sur les processeurs modernes, avoir une bonne densité de code a un impact secondaire sur les performances. En effet, les processeurs modernes ont une mémoire cache qui sert à la fois pour les données, mais aussi pour les instructions chargées depuis la mémoire. Les instructions sont conservées dans le cache après leur exécution, pour une exécution ultérieure (ce qui implique une boucle). Si une instruction est dans le cache, elle est lue directement depuis le cache, sans passer par un long accès en mémoire RAM. Les processeurs modernes ont notamment un petit cache d'instruction, spécialement dédié aux instructions. Plus la densité de code est bonne, plus le cache d'instruction pourra contenir d'instructions, plus il sera efficace, plus il filtrera d'accès mémoire. ===Un petit historique de la distinction entre processeurs CISC et RISC=== Les jeux d'instructions CISC sont les plus anciens et étaient à la mode jusqu'à la fin des années 1980. À cette époque, on programmait rarement avec des langages de haut niveau et beaucoup de programmeurs codaient en assembleur. Avoir un jeu d'instruction complexe, avec des instructions de "haut niveau" facilitait la vie des programmeurs. Et leur meilleure densité de code était un sérieux avantage, car la mémoire était rare et chère. Au cours des années 70-80, la mémoire est devenue moins chère et les langages de haut niveau sont devenus la norme. Le contexte technologique ayant changé, les processeurs CISC étaient à réévaluer. Est-ce que les instructions complexes des processeurs CISC sont vraiment utiles ? Pour le programmeur qui écrit ses programmes en assembleur, elles le sont. Mais avec les langages de haut niveau, la réponse dépend de l'efficacité des compilateurs. Des analyses assez anciennes, effectuées par IBM, DEC et quelques laboratoires de recherche, ont montré que les compilateurs utilisaient les instructions ''load-op'' correctement, mais n'utilisaient pas les instructions complexes. Les analyses plus récentes fournissent la même conclusion. La faible densité de code n'était pas un problème, vu que les mémoires ont une capacité suffisante, encore que ce soit à nuancer. L'idée de créer des processeurs RISC commença à germer. Mais les processeurs RISC durent attendre un peu avant de percer. Par exemple, l'IBM 801, un processeur au jeu d'instruction très sobre, fût un véritable échec commercial. C'est dans les années 1980 que les processeurs possédant un jeu d'instruction simple devinrent à la mode. Cette année-là, un scientifique de l'université de Berkeley décida de créer un processeur possédant un jeu d'instruction contenant seulement un nombre réduit d'instructions simples, possédant une architecture particulière. Ce processeur était assez novateur et incorporait de nombreuses améliorations qu'on retrouve encore dans nos processeurs haute performances actuels, ce qui fit son succès : les processeurs RISC étaient nés. Les CISC et les RISC ont chacun des avantages et des inconvénients, qui rendent le RISC/CISC adapté ou pas selon la situation. Par exemple, on mettra souvent un processeur RISC dans un système embarqué, devant consommer très peu et être peu cher. Mais de nos jours, la performance d'un processeur dépend assez peu du fait que le processeur soit un RISC ou un CISC. Les processeurs modernes disposent de tellement de transistors qu'implémenter des instructions complexes a un impact négligeable. Pour donner une référence, le support des instructions complexes sur les processeurs x86 est estimé à 2 à 3% des transistors de la puce, alors que 50% du budget en transistor des processeurs modernes part dans le cache. Les processeurs actuels sont de plus en plus difficiles à ranger dans des catégories précises. Les processeurs actuels sont conçus d'une façon plus pragmatique : au lieu de respecter à la lettre les principes du RISC et du CISC, on préfère intégrer les instructions qui fonctionnent, peu importe qu'elles viennent de processeurs purement RISC ou CISC. Par exemple, les processeurs ARM récents ont intégré des instructions de copie mémoire, qui sont assez ''borderline'' et sont plutôt attendues sur les processeurs CISC. En parallèle des architectures CISC et RISC, d'autres classes de jeux d'instructions sont apparus. Nous verrons beaucoup de jeux d'instructions dans la suite du cours. Entre les architectures à capacité, les processeurs VLIW, les architectures ''dataflow'', les processeurs SIMD, les ''Digital Signal Processor'', les ''transport-triggered architectures'', les architectures associatives, les architectures neuromorphiques et les achitectures systoliques, il y aura de quoi faire. La plupart de ces jeux d'instructions sont implantés dans des processeurs spécialisés, qu'on fabrique pour une utilisation particulière. Malheureusement, nous ne pouvons pas en parler pour le moment. La suite de ce chapitre va se focaliser sur des jeu d'instruction proches du RISC ou du CISC. ==Les processeurs avec plusieurs jeu d'instruction== Il faut signaler que certains processeur supportent plusieurs jeu d'instruction à la fois. Les processeurs x86 des PC sont dans ce cas, mais aussi les processeurs ARM utilisés partout ailleurs. ===Le x86 : le passage de CPU 8 bits à 16, puis 32 et enfin 64 bits=== Le cas des processeurs x86 est assez simple à comprendre. Ils ont beaucoup évolués dans le temps, commençant comme des processeurs 8 bits, puis 16 bits, suivis par des 32 bits et enfin des processeurs 64 bits. Et à chaque transition, le jeu d'instruction a évolué. Le x86 8 bit n'est pas le même que le x86 16 bits, qui est lui-même différent du x86 32 bits, etc. La raison est que pour étendre les adresses, il fallait modifier l'encodage des instructions. Et quitte à modifier l'encodage des instructions, autant en rajouter d'autres, et supprimer des instructions devenus inutiles. Une autre raison est qu'Intel et AMD ont rajouté des registres lors du passage au 32 bits, et idem lors du passage au 64 bits. Le jeu d'instruction x86 16 bits avait trop peu de registres pour des besoins actuels, ce qui fait qu'Intel et AMD ont décidé d'en rajouter. Et vu que rajouter des registres impactait l'encodage des instructions, cela ne pouvait se faire qu'à l'occasion d'un changement de jeu d'instruction. Une dernière raison est que la mémoire virtuelle n'est pas gérée de la même manière entre les processeurs 16, 32 et 64 bits. Mais nous ne pouvons en parler à ce stade du cours. Mais là encore, cela impacte le jeu d'instruction et le modèle mémoire du processeur, bien que d'une manière assez limitée. Cela revient à ajouter des registres, en supprimer d'autres, modifier l'interprétation de registres existants, etc. Ces changements se sont fait progressivement, ce qui fait que les jeux d'instruction 8/16/32/64 bits se ressemblent beaucoup, mais au point d'être parfaitement compatible. Exécutez un code prévu pour un CPU 16 bits sur un CPU 32 bits, vous aurez des problèmes. Pour éviter cela, les processeurs x86 supportent les différents modes d’instruction existants, par souci de compatibilité. Il est possible de configurer un processeur x86 en mode 16 bits, pour lui faire exécuter du code 16 bits directement, sans interprétation ou émulation d'instruction via exceptions matérielles. Ou encore, on peut mettre un CPU 64 bits en mode 32 bits, pour qu'il exécute du code 32 bits. Les modes en question sont : * le mode réel en 8 et 16 bits ; * le mode protégé 16 bit et sa variante 32 bits ; * le mode long en 64 bits, avec deux sous-modes de compatibilité pour exécuter du code 16 et 32 bits. Si l'ordinateur dispose d'un BIOS, il est démarré en mode réel, puis il passe en mode long pour laisser la main à un système d'exploitation 64 bits. Le mode protégé n'est pas utilisé, sauf éventuellement pour des besoins d'émulation. Si l'ordinateur dispose d'un UEFI, le processeur est lancé en mode long et y reste, que ce soit pour l'UEFI ou le système d'exploitation ou autre. Les modes réels et protégés ne sont utilisés que quand il faut émuler du vieux code. Il existe d'autres modes, comme le ''System Management Mode'' qui laisse la main au BIOS/UEFI, et un mode ''Virtual 8086'' qu'on ne peut pas expliquer à ce moment. Les deux seront cependant détaillés dans quelques chapitres. Le ''System Management Mode'' sera abordé dans le chapitre sur les interruptions, le mode ''Virtual 8086'' sera abordé dans le chapitre sur la virtualisation accélérée en matériel. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|Modes supportés par les CPU x86.]] Les processeurs ARM sont aussi dans ce cas. Leur jeu d'instruction de base a beaucoup évolué dans le temps. L'ARM de base était un jeu d'instruction 32 bits, mais une variante 64 bits a vu le jour par la suite. Comme pour les CPU x86, les CPU ARM 64 bits supportent deux modes d'exécution : un 32 bits et un 64 bits, là encore pour des questions de compatibilité. ===Le jeu d'instruction z-architecture d'IBM=== Les processeurs des ''mainframes'' IBM sont dans un cas similaire à celui du x86, mais avec un bagage encore plus long d'une décennies. Ses premiers ''mainframes'' étaient le System/360 (1964), le System/370 (1970) et ses dérivés, et le Systems Architecture/390 (ESA/390, 1990). Ils utilisaient un jeu d'instruction propriétaire, qui a été étendu progressivement le temps, pour passer d'un adressage de 24 bits sur le 360 à 31 bits pour le 390. Et leur jeu d'instruction a servit de base pour un jeu d'instruction 64 bits, appelé '''z-architecture'''. Le ''z-architecture'' est rétrocompatible les jeux d'instructions de ces ''mainframes''. Il est donc capable de fonctionner en mode 24 bits, 31 bits et 64 bits. Un peu comme les jeux d'instructions x86, sauf que c'est la taille des adresses qui change, pas la taille des données. Les ''mainframes'' géraient des données de 32 bits à la base, seules les adresses étaient plus courtes. La compatibilité était donc totalement différente. De nos jours, adresses et données ont été étendues à 64 bits. Un point sur lequel le ''z-architecture'' se distingue du x86 est qu'il permet de changer d'adressage à la volée. Il supporte des instructions de contrôle pour passer du mode 64 bits à 31 bits, de 31 à 24, ou autre. Deux d'entre elles sont les instructions ''Branch And Save And Set Mode'' (BASSM) et ''Branch and Set Mode'' (BSM). Vous remarquerez que ce sont des branchements. Mais il y a aussi une instruction ''program call'' qui font un appel de fonction, en plus de changer de mode d'adressage. Elles servent pour la compatibilité avec les bibliothèques logicielles (souvent appelées à tord "''librairies''") d'antan. Elles permettent d'appeler des fonctions codées en 24/31 bits dans un programme 64 bits. Le 360 intégrait 16 registres généraux de 32 bits, qui ont été conservés sur tous les processeurs suivants. ===Les jeux d'instruction compacts des processeurs RISC=== Quelques CPU RISC 32 bits supportaient un '''jeu d'instruction compact''', en plus de leur d'instruction classique. Il s'agissait d'un second jeu d’instruction avec une densité de code excellente, qui avait pour but de diminuer la taille des programmes. La technique a été introduite sur des CPU 32 bits, et le jeu d'instruction utilisait des instructions codées sur 16 bits. L'exemple type est celui des Les processeurs ARM, qui incorporent un jeu d'instruction compact appelé ''thumb'', que nous allons détailler dans ce qui suit. Le mode ''thumb'' était présent sur les processeurs ARM 32 bits, mais il a été conservé lors du passage au 64 bits. Le ''thumb'' encode les instructions sur 16 bits, soit deux fois moins que l'ARM 32 bits normal. Il ne s'agissait pas d'un jeu d'instruction totalement séparé de l'ARM normal, mais d'un sous-ensemble de l'ARM normal, utilisant un encodage différent. Concrètement, toute instruction ''thumb'' 16 bits a un équivalent en ARM 32 bits. Par contre, la réciproque n'est pas vraie. Et l'encodage des deux instructions n'est pas le même, ce qui est évident vu que les instructions ''thumb'' sont encodées sur 16 bits et les équivalentes sur 32. Les instructions ''thumb'' ont des limitations que les instructions ARM 32 bits n'ont pas. Par exemple, les instructions ''thumb'' ont un accès restreint aux registres de status et du co-processeur. De plus, la majorité des instructions ''thumb'' n'ont accès qu'à la moitié des registres du processeur. Les instructions arithmétiques et les décalages sont séparés en mode ''thumb'', alors que chaque instructions arithmétiques peut ajouter un décalage à une opérande en mode ARM 32 bits. Les instructions pour le co-processeur ne sont pas disponibles en ''thumb''. Autre exemple : la gestion des instructions à prédicats. L'ARM 32 bits normal utilise la prédication totale, à savoir que toutes les instructions s'exécutent ou non selon la valeur d'un résultat de comparaison, stocké dans un registre de prédicat. Toute instruction encode donc quelques bits pour choisir ce registre. Cela est supprimé avec ''thumb''. A part quelques branchements, les instructions ne sont pas prédicatées et s'exécutent inconditionnellement. Les deux jeux d'instruction ont un mode d'exécution chacun, ce qui veut dire que le processeur est configuré pour fonctionner en mode ''thumb'' ou en mode ARM 32 bits. En clair, on ne peut pas mélanger les deux jeu d'instructions en même temps. La raison est qu'une même suite de bit correspondait soit à une instruction ARM normale, soit à deux instructions thumbs. Pour faire la différence, il faut préciser dans quel mode d'exécution le CPU est : mode ARM ou mode ''thumb''. Le processeur interprète les instructions correctement en tenant compte de ce mode. Niveau implémentation, la gestion des instructions ''thumb'' est assez simple : le processeur convertit les instructions ''thumb'' en leur équivalent ARM 32 bits, juste après les avoir chargées. Une seconde version du jeu d'instruction ''thumb'' a vu le jour, sur les processeurs ARMv6T2, avec ''thumb-2''. Elle avait pour particularité d'utiliser des instructions de taille variable. Rien d'aussi développé que ce qu'on a sur les CPU CISC, comme les CPU x86. Les instructions ARM ''thumb-2'' faisaient soit 16 bits, soit 32 bits. Concrètement, les instructions 16 bits de ''thumb-1'' étaient reprises à l'identique, ''thumb-2'' se contentait d'ajouter des instructions en plus codées sur 32 bits. Les instructions ajoutées étaient des instruction mémoire, des instructions de configuration du CPU, des instructions de manipulation de bit, et quelques autres. Un ajout particulièrement notable est l'ajout d'une instruction IT, qui active l’exécution conditionnelle des instructions suivantes. L'instruction teste une condition et calcule un prédicat, qui est ensuite utilisé pour décider de l'exécution des instructions suivantes. L'exécution conditionnelle marche jusqu'à 4 instructions consécutives. Le nombre exact est configurable et va de 1 à 4 instructions consécutives après l'instruction IT. La condition est configurable pour les 1 à 4 instructions : chaque instruction peut s'exécuter si la condition est vraie, ou au contraire fausse. Un masque de 4 bits décide de quelle condition utiliser pour chaque instruction. Il y a un bit par instruction, qui vaut 1 si la condition doit être vérifiée, 0 si on veut la condition inverse. Il faut noter que quelques processeurs ARM implémentent uniquement le jeu d'instruction ''thumb-1/2'', pas l'ARM 32 bits normal. Ce sont des processeurs faible performance, à destination de systèmes embarqués, qui ont besoin d'une densité de code correcte, comme les CPU ARM Cortex série M. : Il est arrivé que certains CPU ARM en supportent trois : le jeu d'instruction ARM 32 bits classique, le mode ''Thumb'' qu'on décrira plus bas, et une extension nommée Jazelle JBX qui accélérait les programmes écrits en Java (elle exécutait du ''bytecode'' JAVA sur le processeur, nous détaillerons cela dans un autre chapitre). Les CPU ARM n'étaient pas les seuls à utiliser la même technique. Les CPU SuperH d'Hitachi ont été les premiers à implémenter cette technique, avant ARM. Lors du passage de 16 à 32 bits, ces CPU Hitachi ont ajouté un jeu d'instruction de 16 bits à leurs CPU 32 bits, très similaire au jeu d'instruction 16 bits d'avant. Et c'est ce jeu d'instruction compact qui a servi d'inspiration à ARM. Les CPU MIPS ont tenté la même approche, avec l'inclusion du jeu d’instruction MIPS16e, de 16 bits lui aussi. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=L'encodage des instructions | prevText=L'encodage des instructions | next=La pile d'appel et les fonctions | nextText=La pile d'appel et les fonctions }} </noinclude> lfmka8a7wk2xi9wwamvhvalbmkcsr5s 774184 774183 2026-10-04T23:41:42Z Mewtow 31375 /* Le jeu d'instruction z-architecture d'IBM */ 774184 wikitext text/x-wiki Les instructions d'un processeur dépendent fortement du processeur utilisé. Le '''jeu d'instructions''' d'un processeur définit la liste des instructions supportées, ainsi que la manière dont elles sont encodées en mémoire. Le jeu d'instruction des PC actuels est le x86, un jeu d'instructions particulièrement ancien, apparu en 1978. Les macintoshs utilisent un jeu d'instruction différent : le PowerPC. Mais les architectures x86 et Power PC ne sont pas les seules au monde : il existe d'autres types d'architectures qui sont très utilisées dans le monde de l’informatique embarquée et dans tout ce qui est tablettes et téléphones portables derniers cris. On peut citer notamment les architectures ARM, MIPS et SPARC. Pour résumer, il existe différents jeux d'instructions, que l'on peut classer suivant divers critères. ==Les jeux d'instruction RISC vs CISC== Dans le chapitre précédent, nous avons vu comment sont encodées les instructions, ainsi que de nombreux paramètres : les modes d'adressage supportés, leur encodage, la taille des instructions, l'encodage de l'opcode, etc. Et tous ces paramètres sont suffisant pour parler des processeurs RISC et CISC. Il s'agit d'une classification qui sépare les processeurs en deux catégories : * les '''RISC''' (''reduced instruction set computer''), au jeu d'instruction simple ; * et les '''CISC''' (''complex instruction set computer''), qui ont un jeu d'instruction étoffé. Reste à expliquer ce que l'on veut dire quand on parler de jeu d'instruction "simple" ou "complexe". Et la différence n'est pas simple. Surtout que le terme CISC regroupe, comme on le verra, des architectures bien différentes. ===Les différences entre CISC et RISC=== La différence la plus intuitive est le nombre d'instructions supporté. Les processeurs CISC supportent beaucoup d'instructions, , alors que les processeurs RISC se contentent d'un petit nombre d'instructions assez simples. Un autre critère, très lié au précédent, est la complexité des instructions en question. Les processeurs CISC incorporent souvent des instructions complexes, comme le calcul de la division, de la racine carrée, de l'exponentielle, des puissances, des fonctions trigonométriques. Plus fréquent, on trouvait des instructions de contrôle élaborées pour gérer les appels de fonction, simplifier l'implémentation des boucles, etc. La différence principale fait la distinction entre les architectures LOAD-STORE (RISC) et celles qui ne le sont pas (CISC). Les processeurs RISC sont des '''architectures LOAD-STORE'''. Sur celles-ci, seules les instructions d'accès mémoire peuvent lire ou écrire en mémoire. Les autres instructions ne peuvent accéder qu'aux registres : elles lisent leurs opérandes dans les registres et enregistrent leur résultat dans les registres. En conséquence, les instructions de calcul ne peuvent prendre que des noms de registres ou des constantes comme opérandes, via les modes d'adressage immédiat et à registre. Pour le dire autrement, elles ne gèrent que les instructions reg-reg et cst-reg, pas les instructions ''load-op''. La distinction se fait uniquement au niveau des instructions de calcul/branchement. Les premiers processeurs RISC se contentaient de deux instructions d'accès mémoire : LOADq et STORE. Elles ont d'ailleurs donné leur nom à ce type d'architecture. Mais dans les faits, les processeurs RISC modernes peuvent gérer d'autres instructions d'accès à la mémoire. Par exemple, les processeurs ARM7 supportent des instructions de copie mémoire-mémoire nommées CPYP, CPYM et CPYE. Et ce n'est pas incompatible avec un processeur RISC, ni avec une architecture LOAD-STORE. De telles instructions ont beau être assez complexes, ça reste des instructions d'accès mémoire. Même si elles font plusieurs accès mémoire par instruction, c'est comme si on avait fusionné un LOAD suivi d'un STORE en une seule instruction. Les architectures LOAD-STORE sont les seules à avoir une stricte séparation entre instructions d'accès mémoire et instructions de calcul. À l'opposé, les processeurs CISC ne sont pas des architectures LOAD-STORE, leurs instructions de calcul/branchement peuvent effectuer des accès mémoire, soit pour aller chercher leurs opérandes en RAM, soit pour écrire un résultat. Elles peuvent même en faire plusieurs si plusieurs opérandes sont en mémoire, encore que ce ne soit pas possible sur tous les jeux d'instructions. Au minimum, les processeurs CISC gèrent les instructions ''load-op''. Quelques processeurs CISC anciens supportent tous les modes d'adressage possibles pour les opérandes et le résultat, qui peuvent tous trois être lus/écrits dans les registres ou la RAM. Les deux propriétés précédentes, à savoir un grand nombre d'instruction et des modes d'adressages complexes, se marient bien avec des instructions de longueur variable. Les instructions d'un processeur CISC sont de taille variable pour diverses raisons, mais la variété des modes d'adressage y est pour beaucoup. Quand une même instruction peut incorporer soit deux noms de registres, soit deux adresses, soit un nom de registre et une adresse, sa taille ne sera pas la même dans les trois cas. Les processeurs RISC ne sont pas concernés par ce problème. Les instructions de calcul n'utilisent que deux-trois modes d'adressages simples, qui demandent d'encoder des registres ou des constantes immédiates de petite taille. Les autres instructions se débrouillent avec des adresses et éventuellement un nom de registre. Le tout peut être encodé sur 32 ou 64 bits sans problèmes. {|class="wikitable" |+ Différences CISC/RISC |- ! Propriété !! CISC !! RISC |- ! scope="col" | Instructions | * Nombre d'instructions élevé, parfois plus d'une centaine. * Parfois, présence d'instructions complexes (fonctions trigonométriques, gestion de texte, autres). * Supportent des types de données complexes : texte, listes chainées, etc. * Instructions de taille variable. | * Faible nombre d'instructions, moins d'une centaine. * Pas d'instruction complexes. * Types supportés limités aux entiers (adresses comprises) et flottants. * Instructions de taille fixe. |- ! scope="col" | Modes d'adressage | * Support des instructions ''load op'', systématique * Souvent, support d'instructions multi-accès (plusieurs accès mémoires par instruction). | * Architecture LOAD-STORE. * Un seul accès mémoire par instruction maximum |- ! scope="col" | Registres | * Peu de registres : rarement plus de 16 registres entiers. | * Beaucoup de registres, souvent plus de 32. |} Les jeux d'instruction CISC sont moins nombreux que les jeu d'instructions RISC. Les jeux d'instructions RISC les plus connu sont ARM, RISC-V, MIPS, Power et PowerPC, PA-RISC, et de nombreux autres. Pour le CISC, le x86 et le z/ architecture d'IBm sont les deux jeux d'instruction restants. A vrai dire, si on omet les anciens jeux d'instruction CISC utilisés sur les anciens ''mainframes'', ce sont les deux seuls utilisés à l'heure actuelle. : Pour ceux qui veulent en savoir plus sur le jeu d'instruction et les processeurs, PA-RISC, ce site vous donnera toutes les informations possibles : [https://www.openpa.net/ Open PA-RISC]. ===Les performances relatives des CISC/RISC=== Pour comprendre quels sont les avantages et désavantages des processeurs CISC comparé au RISC, nous allons étudier leur performance et leur ''code density''. Pour ce qui est de la performance, les choses ne sont pas clairement tranchées. Pour comprendre pourquoi, nous allons partir d'une équation déjà abordée dans un chapitre antérieur, qui donne le temps que met un programme à s'exécuter. Le temps que met un programme pour s’exécuter est le produit : * du nombre moyen d'instructions exécutées par le programme ; * de la durée moyenne d'une instruction, en seconde. : <math>T = N \times T_\text{instruction}</math>, avec N le nombre moyen d'instruction du programme et <math>T_\text{instruction}</math> la durée moyenne d'une instruction. Le nombre moyen d'instructions exécuté par un programme s'appelle l'''Instruction path length'', ou encore '''longueur du chemin d'instruction''' en français. Si on utilise le nombre ''moyen'' d’instructions, c'est car il n'est pas forcément le même d'une exécution à l'autre, notamment en présence de conditions ou de boucles. Le processeurs CISC intègrent des instructions complexes, que les processeurs RISC doivent émuler à partir d'une suite d'opérations plus simples. Par exemple, les instructions ''load-op'' sont émulées avec une instruction LOAD suivie d'une instruction de calcul. En clair, les processeurs CISC ont un avantage pour le paramètre N, la longueur du chemin d'instruction. Par contre, il n'est pas garantit que les instructions complexes soient aussi rapides que la suite d'instruction RISC équivalente, car les processeurs RISC ont un avantage pour le terme <math>T_\text{instruction}</math>. Voyons pourquoi. Les processeurs CISC gèrent un grand nombre d'instructions et de modes d'adressage, ce qui les rend plus gourmands en transistors. Non pas qu'il faille ajouter plus de circuits de calcul, les CISC se débrouillent bien avec les circuits usuels pour les 4 opérations basiques. Le vrai problème est le support des nombreux modes d'adressage, des instructions de taille variable, et le grand nombre de variantes de la même opération. En conséquence, les circuits de contrôle du processeur sont plus complexes et utilisent beaucoup de transistors. Le budget en transistors utilisé pour les circuits de contrôle ne sont pas disponibles pour autre chose, comme de la mémoire cache ou des registres. De plus, cela a tendance à limiter la fréquence du processeur. Les processeurs RISC sont eux plus économes, ils ont moins d'instructions, moins de circuits de contrôle. Ils peuvent utiliser plus de transistors pour autre chose, souvent de la mémoire cache ou des registres. Les processeurs RISC peuvent se permettre d'utiliser beaucoup de registres, ils ont le budget en transistor pour. Et c'est sans compter que la simplicité des circuits de contrôle et des connexions intra-processeur (le bus interne au CPU qu'on verra dans quelques chapitres) fait qu'on peut faire fonctionner le processeur à plus haute fréquence. Si les registres sont plus nombreux sur les architectures RISC, ce n'est pas qu'une question de budget en transistors. Une autre raison est que les architectures LOAD-STORE ont besoin de plus de registres pour faire le même travail que les architectures à registres, du fait de l'absence d'instructions ''load-op''. La ''register pressure'' est donc légèrement plus importante, ce qui est compensée en ajoutant des registres. ===La densité de code relative entre RISC et CISC=== Si la performance ne donne pas de vainqueur clair entre CISC et RISC, il y a cependant un second critère sur lequel la victoire est bien plus nette. Il s'agit de la '''densité de code''', à savoir la taille des programmes mesurée en octets. De nos jours, la taille des programmes n'est pas importante au point d'être un problème, ce n'est pas ca qui prendra plusieurs gibioctets de mémoire. Par contre, au tout début de l'informatique, la mémoire était chère et limitée, avoir des programmes petits était un avantage clair. La taille d'un programme dépend de deux choses : le nombre d'instructions et leur taille. Et ces deux paramètres sont influencés par le caractère CISC/RISC. Comme dit plus haut, les architectures CISC ont un avantage pour le nombre d'instruction N, du fait de la présence d'instructions ''load-op'' et d'instructions complexes. Mais elles ont aussi un avantage pour la taille des instructions. Les instructions des processeurs CISC sont de taille variable, là où celles des processeurs RISC sont de taille fixe. Les instructions CISC ont une taille allant de très courtes à très longues, celles des RISC sont de taille moyenne. Mais le fait d'avoir des instructions très courtes l'emporte sur les processeurs CISC. Une minorité de processeurs RISC arrivent à obtenir une densité de code proche de celle des processeurs CISC. Pour cela, ils ont classes d'instructions de taille différente. Un exemple est jeu d'instruction RISC-V, où il existe des instructions "normales" de 32 bits et des instructions "compressées" de 16 bits. Le processeur charge un mot de 32 bits, ce qui fait qu'il peut lire entre une et deux instructions à la fois. Au tout début de l'instruction, un bit est mis à 0 ou 1 selon que l'instruction soit longue ou courte. Le reste de l'instruction varie suivant sa longueur. De telles instructions de taille quasi-variables permettent de grandement gagner en densité de code. Il faut noter que sur les processeurs modernes, avoir une bonne densité de code a un impact secondaire sur les performances. En effet, les processeurs modernes ont une mémoire cache qui sert à la fois pour les données, mais aussi pour les instructions chargées depuis la mémoire. Les instructions sont conservées dans le cache après leur exécution, pour une exécution ultérieure (ce qui implique une boucle). Si une instruction est dans le cache, elle est lue directement depuis le cache, sans passer par un long accès en mémoire RAM. Les processeurs modernes ont notamment un petit cache d'instruction, spécialement dédié aux instructions. Plus la densité de code est bonne, plus le cache d'instruction pourra contenir d'instructions, plus il sera efficace, plus il filtrera d'accès mémoire. ===Un petit historique de la distinction entre processeurs CISC et RISC=== Les jeux d'instructions CISC sont les plus anciens et étaient à la mode jusqu'à la fin des années 1980. À cette époque, on programmait rarement avec des langages de haut niveau et beaucoup de programmeurs codaient en assembleur. Avoir un jeu d'instruction complexe, avec des instructions de "haut niveau" facilitait la vie des programmeurs. Et leur meilleure densité de code était un sérieux avantage, car la mémoire était rare et chère. Au cours des années 70-80, la mémoire est devenue moins chère et les langages de haut niveau sont devenus la norme. Le contexte technologique ayant changé, les processeurs CISC étaient à réévaluer. Est-ce que les instructions complexes des processeurs CISC sont vraiment utiles ? Pour le programmeur qui écrit ses programmes en assembleur, elles le sont. Mais avec les langages de haut niveau, la réponse dépend de l'efficacité des compilateurs. Des analyses assez anciennes, effectuées par IBM, DEC et quelques laboratoires de recherche, ont montré que les compilateurs utilisaient les instructions ''load-op'' correctement, mais n'utilisaient pas les instructions complexes. Les analyses plus récentes fournissent la même conclusion. La faible densité de code n'était pas un problème, vu que les mémoires ont une capacité suffisante, encore que ce soit à nuancer. L'idée de créer des processeurs RISC commença à germer. Mais les processeurs RISC durent attendre un peu avant de percer. Par exemple, l'IBM 801, un processeur au jeu d'instruction très sobre, fût un véritable échec commercial. C'est dans les années 1980 que les processeurs possédant un jeu d'instruction simple devinrent à la mode. Cette année-là, un scientifique de l'université de Berkeley décida de créer un processeur possédant un jeu d'instruction contenant seulement un nombre réduit d'instructions simples, possédant une architecture particulière. Ce processeur était assez novateur et incorporait de nombreuses améliorations qu'on retrouve encore dans nos processeurs haute performances actuels, ce qui fit son succès : les processeurs RISC étaient nés. Les CISC et les RISC ont chacun des avantages et des inconvénients, qui rendent le RISC/CISC adapté ou pas selon la situation. Par exemple, on mettra souvent un processeur RISC dans un système embarqué, devant consommer très peu et être peu cher. Mais de nos jours, la performance d'un processeur dépend assez peu du fait que le processeur soit un RISC ou un CISC. Les processeurs modernes disposent de tellement de transistors qu'implémenter des instructions complexes a un impact négligeable. Pour donner une référence, le support des instructions complexes sur les processeurs x86 est estimé à 2 à 3% des transistors de la puce, alors que 50% du budget en transistor des processeurs modernes part dans le cache. Les processeurs actuels sont de plus en plus difficiles à ranger dans des catégories précises. Les processeurs actuels sont conçus d'une façon plus pragmatique : au lieu de respecter à la lettre les principes du RISC et du CISC, on préfère intégrer les instructions qui fonctionnent, peu importe qu'elles viennent de processeurs purement RISC ou CISC. Par exemple, les processeurs ARM récents ont intégré des instructions de copie mémoire, qui sont assez ''borderline'' et sont plutôt attendues sur les processeurs CISC. En parallèle des architectures CISC et RISC, d'autres classes de jeux d'instructions sont apparus. Nous verrons beaucoup de jeux d'instructions dans la suite du cours. Entre les architectures à capacité, les processeurs VLIW, les architectures ''dataflow'', les processeurs SIMD, les ''Digital Signal Processor'', les ''transport-triggered architectures'', les architectures associatives, les architectures neuromorphiques et les achitectures systoliques, il y aura de quoi faire. La plupart de ces jeux d'instructions sont implantés dans des processeurs spécialisés, qu'on fabrique pour une utilisation particulière. Malheureusement, nous ne pouvons pas en parler pour le moment. La suite de ce chapitre va se focaliser sur des jeu d'instruction proches du RISC ou du CISC. ==Les processeurs avec plusieurs jeu d'instruction== Il faut signaler que certains processeur supportent plusieurs jeu d'instruction à la fois. Les processeurs x86 des PC sont dans ce cas, mais aussi les processeurs ARM utilisés partout ailleurs. ===Le x86 : le passage de CPU 8 bits à 16, puis 32 et enfin 64 bits=== Le cas des processeurs x86 est assez simple à comprendre. Ils ont beaucoup évolués dans le temps, commençant comme des processeurs 8 bits, puis 16 bits, suivis par des 32 bits et enfin des processeurs 64 bits. Et à chaque transition, le jeu d'instruction a évolué. Le x86 8 bit n'est pas le même que le x86 16 bits, qui est lui-même différent du x86 32 bits, etc. La raison est que pour étendre les adresses, il fallait modifier l'encodage des instructions. Et quitte à modifier l'encodage des instructions, autant en rajouter d'autres, et supprimer des instructions devenus inutiles. Une autre raison est qu'Intel et AMD ont rajouté des registres lors du passage au 32 bits, et idem lors du passage au 64 bits. Le jeu d'instruction x86 16 bits avait trop peu de registres pour des besoins actuels, ce qui fait qu'Intel et AMD ont décidé d'en rajouter. Et vu que rajouter des registres impactait l'encodage des instructions, cela ne pouvait se faire qu'à l'occasion d'un changement de jeu d'instruction. Une dernière raison est que la mémoire virtuelle n'est pas gérée de la même manière entre les processeurs 16, 32 et 64 bits. Mais nous ne pouvons en parler à ce stade du cours. Mais là encore, cela impacte le jeu d'instruction et le modèle mémoire du processeur, bien que d'une manière assez limitée. Cela revient à ajouter des registres, en supprimer d'autres, modifier l'interprétation de registres existants, etc. Ces changements se sont fait progressivement, ce qui fait que les jeux d'instruction 8/16/32/64 bits se ressemblent beaucoup, mais au point d'être parfaitement compatible. Exécutez un code prévu pour un CPU 16 bits sur un CPU 32 bits, vous aurez des problèmes. Pour éviter cela, les processeurs x86 supportent les différents modes d’instruction existants, par souci de compatibilité. Il est possible de configurer un processeur x86 en mode 16 bits, pour lui faire exécuter du code 16 bits directement, sans interprétation ou émulation d'instruction via exceptions matérielles. Ou encore, on peut mettre un CPU 64 bits en mode 32 bits, pour qu'il exécute du code 32 bits. Les modes en question sont : * le mode réel en 8 et 16 bits ; * le mode protégé 16 bit et sa variante 32 bits ; * le mode long en 64 bits, avec deux sous-modes de compatibilité pour exécuter du code 16 et 32 bits. Si l'ordinateur dispose d'un BIOS, il est démarré en mode réel, puis il passe en mode long pour laisser la main à un système d'exploitation 64 bits. Le mode protégé n'est pas utilisé, sauf éventuellement pour des besoins d'émulation. Si l'ordinateur dispose d'un UEFI, le processeur est lancé en mode long et y reste, que ce soit pour l'UEFI ou le système d'exploitation ou autre. Les modes réels et protégés ne sont utilisés que quand il faut émuler du vieux code. Il existe d'autres modes, comme le ''System Management Mode'' qui laisse la main au BIOS/UEFI, et un mode ''Virtual 8086'' qu'on ne peut pas expliquer à ce moment. Les deux seront cependant détaillés dans quelques chapitres. Le ''System Management Mode'' sera abordé dans le chapitre sur les interruptions, le mode ''Virtual 8086'' sera abordé dans le chapitre sur la virtualisation accélérée en matériel. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|Modes supportés par les CPU x86.]] Les processeurs ARM sont aussi dans ce cas. Leur jeu d'instruction de base a beaucoup évolué dans le temps. L'ARM de base était un jeu d'instruction 32 bits, mais une variante 64 bits a vu le jour par la suite. Comme pour les CPU x86, les CPU ARM 64 bits supportent deux modes d'exécution : un 32 bits et un 64 bits, là encore pour des questions de compatibilité. ===Le jeu d'instruction z-architecture d'IBM=== Les processeurs des ''mainframes'' IBM sont dans un cas similaire à celui du x86, mais avec un bagage encore plus long d'une décennies. Ses premiers ''mainframes'' étaient le System/360 (1964), le System/370 (1970) et ses dérivés, et le Systems Architecture/390 (ESA/390, 1990). Ils utilisaient un jeu d'instruction propriétaire, qui a été étendu progressivement le temps, pour passer d'un adressage de 24 bits sur le 360 à 31 bits pour le 390. Et leur jeu d'instruction a servit de base pour un jeu d'instruction 64 bits, appelé '''z-architecture'''. Le ''z-architecture'' est rétrocompatible les jeux d'instructions de ces ''mainframes''. Il est donc capable de fonctionner en en ''mode 24 bits'', en ''mode 31 bits'', et en ''mode 64 bits''. Un peu comme les jeux d'instructions x86, sauf que c'est la taille des adresses qui change, pas la taille des données. Les ''mainframes'' géraient des données de 32 bits à la base, seules les adresses étaient plus courtes. La compatibilité était donc totalement différente. De nos jours, adresses et données ont été étendues à 64 bits. Un point sur lequel le ''z-architecture'' se distingue du x86 est qu'il permet de changer d'adressage à la volée. Il supporte des instructions de contrôle pour passer du mode 64 bits à 31 bits, de 31 à 24, ou autre. Deux d'entre elles sont les instructions ''Branch And Save And Set Mode'' (BASSM) et ''Branch and Set Mode'' (BSM). Vous remarquerez que ce sont des branchements. Mais il y a aussi une instruction ''program call'' qui font un appel de fonction, en plus de changer de mode d'adressage. Elles servent pour la compatibilité avec les bibliothèques logicielles (souvent appelées à tord "''librairies''") d'antan. Elles permettent d'appeler des fonctions codées en 24/31 bits dans un programme 64 bits. Les CPU ''z-architecture'' permettent de configurer la taille des opérandes séparémment de la taille des adresses. Ainsi, ils peuvent fonctionner en mode 24 bits, mais avec des nombres entiers de 64 bits. Les modes 24/31/64 bits désignent donc la taille des adresses supportées, pas la taille des opérandes. Pour la taille des opérandes, le processeur peut utiliser aussi bien des opérandes de 32 ou 64 bits. Pour cela, il duplique les instructions arithmétiques, pour qu'elles aient des versions 32 et 64 bits. Les deux types d'instructions peuvent être mixées dans un même programme, ce qui permet d'exécuter des fonctions provenant de bibliothèques codées en 32 bits, dans un programme qui manipule des opérandes 64 bits. Le 360 intégrait 16 registres généraux de 32 bits, qui ont été conservés sur tous les processeurs suivants. Ils ont été étendus à 64 bits lors du passage à la ''z-architecture''. ===Les jeux d'instruction compacts des processeurs RISC=== Quelques CPU RISC 32 bits supportaient un '''jeu d'instruction compact''', en plus de leur d'instruction classique. Il s'agissait d'un second jeu d’instruction avec une densité de code excellente, qui avait pour but de diminuer la taille des programmes. La technique a été introduite sur des CPU 32 bits, et le jeu d'instruction utilisait des instructions codées sur 16 bits. L'exemple type est celui des Les processeurs ARM, qui incorporent un jeu d'instruction compact appelé ''thumb'', que nous allons détailler dans ce qui suit. Le mode ''thumb'' était présent sur les processeurs ARM 32 bits, mais il a été conservé lors du passage au 64 bits. Le ''thumb'' encode les instructions sur 16 bits, soit deux fois moins que l'ARM 32 bits normal. Il ne s'agissait pas d'un jeu d'instruction totalement séparé de l'ARM normal, mais d'un sous-ensemble de l'ARM normal, utilisant un encodage différent. Concrètement, toute instruction ''thumb'' 16 bits a un équivalent en ARM 32 bits. Par contre, la réciproque n'est pas vraie. Et l'encodage des deux instructions n'est pas le même, ce qui est évident vu que les instructions ''thumb'' sont encodées sur 16 bits et les équivalentes sur 32. Les instructions ''thumb'' ont des limitations que les instructions ARM 32 bits n'ont pas. Par exemple, les instructions ''thumb'' ont un accès restreint aux registres de status et du co-processeur. De plus, la majorité des instructions ''thumb'' n'ont accès qu'à la moitié des registres du processeur. Les instructions arithmétiques et les décalages sont séparés en mode ''thumb'', alors que chaque instructions arithmétiques peut ajouter un décalage à une opérande en mode ARM 32 bits. Les instructions pour le co-processeur ne sont pas disponibles en ''thumb''. Autre exemple : la gestion des instructions à prédicats. L'ARM 32 bits normal utilise la prédication totale, à savoir que toutes les instructions s'exécutent ou non selon la valeur d'un résultat de comparaison, stocké dans un registre de prédicat. Toute instruction encode donc quelques bits pour choisir ce registre. Cela est supprimé avec ''thumb''. A part quelques branchements, les instructions ne sont pas prédicatées et s'exécutent inconditionnellement. Les deux jeux d'instruction ont un mode d'exécution chacun, ce qui veut dire que le processeur est configuré pour fonctionner en mode ''thumb'' ou en mode ARM 32 bits. En clair, on ne peut pas mélanger les deux jeu d'instructions en même temps. La raison est qu'une même suite de bit correspondait soit à une instruction ARM normale, soit à deux instructions thumbs. Pour faire la différence, il faut préciser dans quel mode d'exécution le CPU est : mode ARM ou mode ''thumb''. Le processeur interprète les instructions correctement en tenant compte de ce mode. Niveau implémentation, la gestion des instructions ''thumb'' est assez simple : le processeur convertit les instructions ''thumb'' en leur équivalent ARM 32 bits, juste après les avoir chargées. Une seconde version du jeu d'instruction ''thumb'' a vu le jour, sur les processeurs ARMv6T2, avec ''thumb-2''. Elle avait pour particularité d'utiliser des instructions de taille variable. Rien d'aussi développé que ce qu'on a sur les CPU CISC, comme les CPU x86. Les instructions ARM ''thumb-2'' faisaient soit 16 bits, soit 32 bits. Concrètement, les instructions 16 bits de ''thumb-1'' étaient reprises à l'identique, ''thumb-2'' se contentait d'ajouter des instructions en plus codées sur 32 bits. Les instructions ajoutées étaient des instruction mémoire, des instructions de configuration du CPU, des instructions de manipulation de bit, et quelques autres. Un ajout particulièrement notable est l'ajout d'une instruction IT, qui active l’exécution conditionnelle des instructions suivantes. L'instruction teste une condition et calcule un prédicat, qui est ensuite utilisé pour décider de l'exécution des instructions suivantes. L'exécution conditionnelle marche jusqu'à 4 instructions consécutives. Le nombre exact est configurable et va de 1 à 4 instructions consécutives après l'instruction IT. La condition est configurable pour les 1 à 4 instructions : chaque instruction peut s'exécuter si la condition est vraie, ou au contraire fausse. Un masque de 4 bits décide de quelle condition utiliser pour chaque instruction. Il y a un bit par instruction, qui vaut 1 si la condition doit être vérifiée, 0 si on veut la condition inverse. Il faut noter que quelques processeurs ARM implémentent uniquement le jeu d'instruction ''thumb-1/2'', pas l'ARM 32 bits normal. Ce sont des processeurs faible performance, à destination de systèmes embarqués, qui ont besoin d'une densité de code correcte, comme les CPU ARM Cortex série M. : Il est arrivé que certains CPU ARM en supportent trois : le jeu d'instruction ARM 32 bits classique, le mode ''Thumb'' qu'on décrira plus bas, et une extension nommée Jazelle JBX qui accélérait les programmes écrits en Java (elle exécutait du ''bytecode'' JAVA sur le processeur, nous détaillerons cela dans un autre chapitre). Les CPU ARM n'étaient pas les seuls à utiliser la même technique. Les CPU SuperH d'Hitachi ont été les premiers à implémenter cette technique, avant ARM. Lors du passage de 16 à 32 bits, ces CPU Hitachi ont ajouté un jeu d'instruction de 16 bits à leurs CPU 32 bits, très similaire au jeu d'instruction 16 bits d'avant. Et c'est ce jeu d'instruction compact qui a servi d'inspiration à ARM. Les CPU MIPS ont tenté la même approche, avec l'inclusion du jeu d’instruction MIPS16e, de 16 bits lui aussi. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=L'encodage des instructions | prevText=L'encodage des instructions | next=La pile d'appel et les fonctions | nextText=La pile d'appel et les fonctions }} </noinclude> 1istjkzjj2t1lw86xtb5gw6ydnwe34u 774185 774184 2026-10-04T23:49:42Z Mewtow 31375 /* Le jeu d'instruction z-architecture d'IBM */ 774185 wikitext text/x-wiki Les instructions d'un processeur dépendent fortement du processeur utilisé. Le '''jeu d'instructions''' d'un processeur définit la liste des instructions supportées, ainsi que la manière dont elles sont encodées en mémoire. Le jeu d'instruction des PC actuels est le x86, un jeu d'instructions particulièrement ancien, apparu en 1978. Les macintoshs utilisent un jeu d'instruction différent : le PowerPC. Mais les architectures x86 et Power PC ne sont pas les seules au monde : il existe d'autres types d'architectures qui sont très utilisées dans le monde de l’informatique embarquée et dans tout ce qui est tablettes et téléphones portables derniers cris. On peut citer notamment les architectures ARM, MIPS et SPARC. Pour résumer, il existe différents jeux d'instructions, que l'on peut classer suivant divers critères. ==Les jeux d'instruction RISC vs CISC== Dans le chapitre précédent, nous avons vu comment sont encodées les instructions, ainsi que de nombreux paramètres : les modes d'adressage supportés, leur encodage, la taille des instructions, l'encodage de l'opcode, etc. Et tous ces paramètres sont suffisant pour parler des processeurs RISC et CISC. Il s'agit d'une classification qui sépare les processeurs en deux catégories : * les '''RISC''' (''reduced instruction set computer''), au jeu d'instruction simple ; * et les '''CISC''' (''complex instruction set computer''), qui ont un jeu d'instruction étoffé. Reste à expliquer ce que l'on veut dire quand on parler de jeu d'instruction "simple" ou "complexe". Et la différence n'est pas simple. Surtout que le terme CISC regroupe, comme on le verra, des architectures bien différentes. ===Les différences entre CISC et RISC=== La différence la plus intuitive est le nombre d'instructions supporté. Les processeurs CISC supportent beaucoup d'instructions, , alors que les processeurs RISC se contentent d'un petit nombre d'instructions assez simples. Un autre critère, très lié au précédent, est la complexité des instructions en question. Les processeurs CISC incorporent souvent des instructions complexes, comme le calcul de la division, de la racine carrée, de l'exponentielle, des puissances, des fonctions trigonométriques. Plus fréquent, on trouvait des instructions de contrôle élaborées pour gérer les appels de fonction, simplifier l'implémentation des boucles, etc. La différence principale fait la distinction entre les architectures LOAD-STORE (RISC) et celles qui ne le sont pas (CISC). Les processeurs RISC sont des '''architectures LOAD-STORE'''. Sur celles-ci, seules les instructions d'accès mémoire peuvent lire ou écrire en mémoire. Les autres instructions ne peuvent accéder qu'aux registres : elles lisent leurs opérandes dans les registres et enregistrent leur résultat dans les registres. En conséquence, les instructions de calcul ne peuvent prendre que des noms de registres ou des constantes comme opérandes, via les modes d'adressage immédiat et à registre. Pour le dire autrement, elles ne gèrent que les instructions reg-reg et cst-reg, pas les instructions ''load-op''. La distinction se fait uniquement au niveau des instructions de calcul/branchement. Les premiers processeurs RISC se contentaient de deux instructions d'accès mémoire : LOADq et STORE. Elles ont d'ailleurs donné leur nom à ce type d'architecture. Mais dans les faits, les processeurs RISC modernes peuvent gérer d'autres instructions d'accès à la mémoire. Par exemple, les processeurs ARM7 supportent des instructions de copie mémoire-mémoire nommées CPYP, CPYM et CPYE. Et ce n'est pas incompatible avec un processeur RISC, ni avec une architecture LOAD-STORE. De telles instructions ont beau être assez complexes, ça reste des instructions d'accès mémoire. Même si elles font plusieurs accès mémoire par instruction, c'est comme si on avait fusionné un LOAD suivi d'un STORE en une seule instruction. Les architectures LOAD-STORE sont les seules à avoir une stricte séparation entre instructions d'accès mémoire et instructions de calcul. À l'opposé, les processeurs CISC ne sont pas des architectures LOAD-STORE, leurs instructions de calcul/branchement peuvent effectuer des accès mémoire, soit pour aller chercher leurs opérandes en RAM, soit pour écrire un résultat. Elles peuvent même en faire plusieurs si plusieurs opérandes sont en mémoire, encore que ce ne soit pas possible sur tous les jeux d'instructions. Au minimum, les processeurs CISC gèrent les instructions ''load-op''. Quelques processeurs CISC anciens supportent tous les modes d'adressage possibles pour les opérandes et le résultat, qui peuvent tous trois être lus/écrits dans les registres ou la RAM. Les deux propriétés précédentes, à savoir un grand nombre d'instruction et des modes d'adressages complexes, se marient bien avec des instructions de longueur variable. Les instructions d'un processeur CISC sont de taille variable pour diverses raisons, mais la variété des modes d'adressage y est pour beaucoup. Quand une même instruction peut incorporer soit deux noms de registres, soit deux adresses, soit un nom de registre et une adresse, sa taille ne sera pas la même dans les trois cas. Les processeurs RISC ne sont pas concernés par ce problème. Les instructions de calcul n'utilisent que deux-trois modes d'adressages simples, qui demandent d'encoder des registres ou des constantes immédiates de petite taille. Les autres instructions se débrouillent avec des adresses et éventuellement un nom de registre. Le tout peut être encodé sur 32 ou 64 bits sans problèmes. {|class="wikitable" |+ Différences CISC/RISC |- ! Propriété !! CISC !! RISC |- ! scope="col" | Instructions | * Nombre d'instructions élevé, parfois plus d'une centaine. * Parfois, présence d'instructions complexes (fonctions trigonométriques, gestion de texte, autres). * Supportent des types de données complexes : texte, listes chainées, etc. * Instructions de taille variable. | * Faible nombre d'instructions, moins d'une centaine. * Pas d'instruction complexes. * Types supportés limités aux entiers (adresses comprises) et flottants. * Instructions de taille fixe. |- ! scope="col" | Modes d'adressage | * Support des instructions ''load op'', systématique * Souvent, support d'instructions multi-accès (plusieurs accès mémoires par instruction). | * Architecture LOAD-STORE. * Un seul accès mémoire par instruction maximum |- ! scope="col" | Registres | * Peu de registres : rarement plus de 16 registres entiers. | * Beaucoup de registres, souvent plus de 32. |} Les jeux d'instruction CISC sont moins nombreux que les jeu d'instructions RISC. Les jeux d'instructions RISC les plus connu sont ARM, RISC-V, MIPS, Power et PowerPC, PA-RISC, et de nombreux autres. Pour le CISC, le x86 et le z/ architecture d'IBm sont les deux jeux d'instruction restants. A vrai dire, si on omet les anciens jeux d'instruction CISC utilisés sur les anciens ''mainframes'', ce sont les deux seuls utilisés à l'heure actuelle. : Pour ceux qui veulent en savoir plus sur le jeu d'instruction et les processeurs, PA-RISC, ce site vous donnera toutes les informations possibles : [https://www.openpa.net/ Open PA-RISC]. ===Les performances relatives des CISC/RISC=== Pour comprendre quels sont les avantages et désavantages des processeurs CISC comparé au RISC, nous allons étudier leur performance et leur ''code density''. Pour ce qui est de la performance, les choses ne sont pas clairement tranchées. Pour comprendre pourquoi, nous allons partir d'une équation déjà abordée dans un chapitre antérieur, qui donne le temps que met un programme à s'exécuter. Le temps que met un programme pour s’exécuter est le produit : * du nombre moyen d'instructions exécutées par le programme ; * de la durée moyenne d'une instruction, en seconde. : <math>T = N \times T_\text{instruction}</math>, avec N le nombre moyen d'instruction du programme et <math>T_\text{instruction}</math> la durée moyenne d'une instruction. Le nombre moyen d'instructions exécuté par un programme s'appelle l'''Instruction path length'', ou encore '''longueur du chemin d'instruction''' en français. Si on utilise le nombre ''moyen'' d’instructions, c'est car il n'est pas forcément le même d'une exécution à l'autre, notamment en présence de conditions ou de boucles. Le processeurs CISC intègrent des instructions complexes, que les processeurs RISC doivent émuler à partir d'une suite d'opérations plus simples. Par exemple, les instructions ''load-op'' sont émulées avec une instruction LOAD suivie d'une instruction de calcul. En clair, les processeurs CISC ont un avantage pour le paramètre N, la longueur du chemin d'instruction. Par contre, il n'est pas garantit que les instructions complexes soient aussi rapides que la suite d'instruction RISC équivalente, car les processeurs RISC ont un avantage pour le terme <math>T_\text{instruction}</math>. Voyons pourquoi. Les processeurs CISC gèrent un grand nombre d'instructions et de modes d'adressage, ce qui les rend plus gourmands en transistors. Non pas qu'il faille ajouter plus de circuits de calcul, les CISC se débrouillent bien avec les circuits usuels pour les 4 opérations basiques. Le vrai problème est le support des nombreux modes d'adressage, des instructions de taille variable, et le grand nombre de variantes de la même opération. En conséquence, les circuits de contrôle du processeur sont plus complexes et utilisent beaucoup de transistors. Le budget en transistors utilisé pour les circuits de contrôle ne sont pas disponibles pour autre chose, comme de la mémoire cache ou des registres. De plus, cela a tendance à limiter la fréquence du processeur. Les processeurs RISC sont eux plus économes, ils ont moins d'instructions, moins de circuits de contrôle. Ils peuvent utiliser plus de transistors pour autre chose, souvent de la mémoire cache ou des registres. Les processeurs RISC peuvent se permettre d'utiliser beaucoup de registres, ils ont le budget en transistor pour. Et c'est sans compter que la simplicité des circuits de contrôle et des connexions intra-processeur (le bus interne au CPU qu'on verra dans quelques chapitres) fait qu'on peut faire fonctionner le processeur à plus haute fréquence. Si les registres sont plus nombreux sur les architectures RISC, ce n'est pas qu'une question de budget en transistors. Une autre raison est que les architectures LOAD-STORE ont besoin de plus de registres pour faire le même travail que les architectures à registres, du fait de l'absence d'instructions ''load-op''. La ''register pressure'' est donc légèrement plus importante, ce qui est compensée en ajoutant des registres. ===La densité de code relative entre RISC et CISC=== Si la performance ne donne pas de vainqueur clair entre CISC et RISC, il y a cependant un second critère sur lequel la victoire est bien plus nette. Il s'agit de la '''densité de code''', à savoir la taille des programmes mesurée en octets. De nos jours, la taille des programmes n'est pas importante au point d'être un problème, ce n'est pas ca qui prendra plusieurs gibioctets de mémoire. Par contre, au tout début de l'informatique, la mémoire était chère et limitée, avoir des programmes petits était un avantage clair. La taille d'un programme dépend de deux choses : le nombre d'instructions et leur taille. Et ces deux paramètres sont influencés par le caractère CISC/RISC. Comme dit plus haut, les architectures CISC ont un avantage pour le nombre d'instruction N, du fait de la présence d'instructions ''load-op'' et d'instructions complexes. Mais elles ont aussi un avantage pour la taille des instructions. Les instructions des processeurs CISC sont de taille variable, là où celles des processeurs RISC sont de taille fixe. Les instructions CISC ont une taille allant de très courtes à très longues, celles des RISC sont de taille moyenne. Mais le fait d'avoir des instructions très courtes l'emporte sur les processeurs CISC. Une minorité de processeurs RISC arrivent à obtenir une densité de code proche de celle des processeurs CISC. Pour cela, ils ont classes d'instructions de taille différente. Un exemple est jeu d'instruction RISC-V, où il existe des instructions "normales" de 32 bits et des instructions "compressées" de 16 bits. Le processeur charge un mot de 32 bits, ce qui fait qu'il peut lire entre une et deux instructions à la fois. Au tout début de l'instruction, un bit est mis à 0 ou 1 selon que l'instruction soit longue ou courte. Le reste de l'instruction varie suivant sa longueur. De telles instructions de taille quasi-variables permettent de grandement gagner en densité de code. Il faut noter que sur les processeurs modernes, avoir une bonne densité de code a un impact secondaire sur les performances. En effet, les processeurs modernes ont une mémoire cache qui sert à la fois pour les données, mais aussi pour les instructions chargées depuis la mémoire. Les instructions sont conservées dans le cache après leur exécution, pour une exécution ultérieure (ce qui implique une boucle). Si une instruction est dans le cache, elle est lue directement depuis le cache, sans passer par un long accès en mémoire RAM. Les processeurs modernes ont notamment un petit cache d'instruction, spécialement dédié aux instructions. Plus la densité de code est bonne, plus le cache d'instruction pourra contenir d'instructions, plus il sera efficace, plus il filtrera d'accès mémoire. ===Un petit historique de la distinction entre processeurs CISC et RISC=== Les jeux d'instructions CISC sont les plus anciens et étaient à la mode jusqu'à la fin des années 1980. À cette époque, on programmait rarement avec des langages de haut niveau et beaucoup de programmeurs codaient en assembleur. Avoir un jeu d'instruction complexe, avec des instructions de "haut niveau" facilitait la vie des programmeurs. Et leur meilleure densité de code était un sérieux avantage, car la mémoire était rare et chère. Au cours des années 70-80, la mémoire est devenue moins chère et les langages de haut niveau sont devenus la norme. Le contexte technologique ayant changé, les processeurs CISC étaient à réévaluer. Est-ce que les instructions complexes des processeurs CISC sont vraiment utiles ? Pour le programmeur qui écrit ses programmes en assembleur, elles le sont. Mais avec les langages de haut niveau, la réponse dépend de l'efficacité des compilateurs. Des analyses assez anciennes, effectuées par IBM, DEC et quelques laboratoires de recherche, ont montré que les compilateurs utilisaient les instructions ''load-op'' correctement, mais n'utilisaient pas les instructions complexes. Les analyses plus récentes fournissent la même conclusion. La faible densité de code n'était pas un problème, vu que les mémoires ont une capacité suffisante, encore que ce soit à nuancer. L'idée de créer des processeurs RISC commença à germer. Mais les processeurs RISC durent attendre un peu avant de percer. Par exemple, l'IBM 801, un processeur au jeu d'instruction très sobre, fût un véritable échec commercial. C'est dans les années 1980 que les processeurs possédant un jeu d'instruction simple devinrent à la mode. Cette année-là, un scientifique de l'université de Berkeley décida de créer un processeur possédant un jeu d'instruction contenant seulement un nombre réduit d'instructions simples, possédant une architecture particulière. Ce processeur était assez novateur et incorporait de nombreuses améliorations qu'on retrouve encore dans nos processeurs haute performances actuels, ce qui fit son succès : les processeurs RISC étaient nés. Les CISC et les RISC ont chacun des avantages et des inconvénients, qui rendent le RISC/CISC adapté ou pas selon la situation. Par exemple, on mettra souvent un processeur RISC dans un système embarqué, devant consommer très peu et être peu cher. Mais de nos jours, la performance d'un processeur dépend assez peu du fait que le processeur soit un RISC ou un CISC. Les processeurs modernes disposent de tellement de transistors qu'implémenter des instructions complexes a un impact négligeable. Pour donner une référence, le support des instructions complexes sur les processeurs x86 est estimé à 2 à 3% des transistors de la puce, alors que 50% du budget en transistor des processeurs modernes part dans le cache. Les processeurs actuels sont de plus en plus difficiles à ranger dans des catégories précises. Les processeurs actuels sont conçus d'une façon plus pragmatique : au lieu de respecter à la lettre les principes du RISC et du CISC, on préfère intégrer les instructions qui fonctionnent, peu importe qu'elles viennent de processeurs purement RISC ou CISC. Par exemple, les processeurs ARM récents ont intégré des instructions de copie mémoire, qui sont assez ''borderline'' et sont plutôt attendues sur les processeurs CISC. En parallèle des architectures CISC et RISC, d'autres classes de jeux d'instructions sont apparus. Nous verrons beaucoup de jeux d'instructions dans la suite du cours. Entre les architectures à capacité, les processeurs VLIW, les architectures ''dataflow'', les processeurs SIMD, les ''Digital Signal Processor'', les ''transport-triggered architectures'', les architectures associatives, les architectures neuromorphiques et les achitectures systoliques, il y aura de quoi faire. La plupart de ces jeux d'instructions sont implantés dans des processeurs spécialisés, qu'on fabrique pour une utilisation particulière. Malheureusement, nous ne pouvons pas en parler pour le moment. La suite de ce chapitre va se focaliser sur des jeu d'instruction proches du RISC ou du CISC. ==Les processeurs avec plusieurs jeu d'instruction== Il faut signaler que certains processeur supportent plusieurs jeu d'instruction à la fois. Les processeurs x86 des PC sont dans ce cas, mais aussi les processeurs ARM utilisés partout ailleurs. ===Le x86 : le passage de CPU 8 bits à 16, puis 32 et enfin 64 bits=== Le cas des processeurs x86 est assez simple à comprendre. Ils ont beaucoup évolués dans le temps, commençant comme des processeurs 8 bits, puis 16 bits, suivis par des 32 bits et enfin des processeurs 64 bits. Et à chaque transition, le jeu d'instruction a évolué. Le x86 8 bit n'est pas le même que le x86 16 bits, qui est lui-même différent du x86 32 bits, etc. La raison est que pour étendre les adresses, il fallait modifier l'encodage des instructions. Et quitte à modifier l'encodage des instructions, autant en rajouter d'autres, et supprimer des instructions devenus inutiles. Une autre raison est qu'Intel et AMD ont rajouté des registres lors du passage au 32 bits, et idem lors du passage au 64 bits. Le jeu d'instruction x86 16 bits avait trop peu de registres pour des besoins actuels, ce qui fait qu'Intel et AMD ont décidé d'en rajouter. Et vu que rajouter des registres impactait l'encodage des instructions, cela ne pouvait se faire qu'à l'occasion d'un changement de jeu d'instruction. Une dernière raison est que la mémoire virtuelle n'est pas gérée de la même manière entre les processeurs 16, 32 et 64 bits. Mais nous ne pouvons en parler à ce stade du cours. Mais là encore, cela impacte le jeu d'instruction et le modèle mémoire du processeur, bien que d'une manière assez limitée. Cela revient à ajouter des registres, en supprimer d'autres, modifier l'interprétation de registres existants, etc. Ces changements se sont fait progressivement, ce qui fait que les jeux d'instruction 8/16/32/64 bits se ressemblent beaucoup, mais au point d'être parfaitement compatible. Exécutez un code prévu pour un CPU 16 bits sur un CPU 32 bits, vous aurez des problèmes. Pour éviter cela, les processeurs x86 supportent les différents modes d’instruction existants, par souci de compatibilité. Il est possible de configurer un processeur x86 en mode 16 bits, pour lui faire exécuter du code 16 bits directement, sans interprétation ou émulation d'instruction via exceptions matérielles. Ou encore, on peut mettre un CPU 64 bits en mode 32 bits, pour qu'il exécute du code 32 bits. Les modes en question sont : * le mode réel en 8 et 16 bits ; * le mode protégé 16 bit et sa variante 32 bits ; * le mode long en 64 bits, avec deux sous-modes de compatibilité pour exécuter du code 16 et 32 bits. Si l'ordinateur dispose d'un BIOS, il est démarré en mode réel, puis il passe en mode long pour laisser la main à un système d'exploitation 64 bits. Le mode protégé n'est pas utilisé, sauf éventuellement pour des besoins d'émulation. Si l'ordinateur dispose d'un UEFI, le processeur est lancé en mode long et y reste, que ce soit pour l'UEFI ou le système d'exploitation ou autre. Les modes réels et protégés ne sont utilisés que quand il faut émuler du vieux code. Il existe d'autres modes, comme le ''System Management Mode'' qui laisse la main au BIOS/UEFI, et un mode ''Virtual 8086'' qu'on ne peut pas expliquer à ce moment. Les deux seront cependant détaillés dans quelques chapitres. Le ''System Management Mode'' sera abordé dans le chapitre sur les interruptions, le mode ''Virtual 8086'' sera abordé dans le chapitre sur la virtualisation accélérée en matériel. [[File:AMD64StateDiagram.svg|centre|vignette|upright=2|Modes supportés par les CPU x86.]] Les processeurs ARM sont aussi dans ce cas. Leur jeu d'instruction de base a beaucoup évolué dans le temps. L'ARM de base était un jeu d'instruction 32 bits, mais une variante 64 bits a vu le jour par la suite. Comme pour les CPU x86, les CPU ARM 64 bits supportent deux modes d'exécution : un 32 bits et un 64 bits, là encore pour des questions de compatibilité. ===Le jeu d'instruction z-architecture d'IBM=== Les processeurs des ''mainframes'' IBM sont dans un cas similaire à celui du x86, mais avec un bagage encore plus long d'une décennies. Ses premiers ''mainframes'' étaient le System/360 (1964), le System/370 (1970) et ses dérivés, et le Systems Architecture/390 (ESA/390, 1990). Ils utilisaient un jeu d'instruction propriétaire, qui a été étendu progressivement le temps, pour passer d'un adressage de 24 bits sur le 360 à 31 bits pour le 390. Et leur jeu d'instruction a servit de base pour un jeu d'instruction 64 bits, appelé '''z-architecture'''. Le ''z-architecture'' est rétrocompatible les jeux d'instructions de ces ''mainframes''. Il est donc capable de fonctionner en en ''mode 24 bits'', en ''mode 31 bits'', et en ''mode 64 bits''. Un peu comme les jeux d'instructions x86, sauf que c'est la taille des adresses qui change, pas la taille des données. Les ''mainframes'' géraient des données de 32 bits à la base, seules les adresses étaient plus courtes. La compatibilité était donc totalement différente. De nos jours, adresses et données ont été étendues à 64 bits. Un point sur lequel le ''z-architecture'' se distingue du x86 est qu'il permet de changer d'adressage à la volée. Il supporte des instructions de contrôle pour passer du mode 64 bits à 31 bits, de 31 à 24, ou autre. Deux d'entre elles sont les instructions ''Branch And Save And Set Mode'' (BASSM) et ''Branch and Set Mode'' (BSM). Vous remarquerez que ce sont des branchements. Mais il y a aussi une instruction ''program call'' qui font un appel de fonction, en plus de changer de mode d'adressage. Elles servent pour la compatibilité avec les bibliothèques logicielles (souvent appelées à tord "''librairies''") d'antan. Elles permettent d'appeler des fonctions codées en 24/31 bits dans un programme 64 bits. Le 360 intégrait 16 registres généraux de 32 bits, qui ont été conservés sur tous les processeurs suivants. Ils ont été étendus à 64 bits lors du passage à la ''z-architecture''. Les CPU ''z-architecture'' permettent de configurer la taille des opérandes séparément de la taille des adresses. Ainsi, ils peuvent fonctionner en mode 24 bits, mais avec des nombres entiers de 64 bits. Les modes 24/31/64 bits désignent donc la taille des adresses supportées, pas la taille des opérandes. Pour la taille des opérandes, le processeur peut utiliser aussi bien des opérandes de 32 ou 64 bits. Pour cela, il duplique les instructions arithmétiques, pour qu'elles aient des versions 32 et 64 bits. Les deux types d'instructions peuvent être mixées dans un même programme, ce qui permet d'exécuter des fonctions provenant de bibliothèques codées en 32 bits, dans un programme qui manipule des opérandes 64 bits. ===Les jeux d'instruction compacts des processeurs RISC=== Quelques CPU RISC 32 bits supportaient un '''jeu d'instruction compact''', en plus de leur d'instruction classique. Il s'agissait d'un second jeu d’instruction avec une densité de code excellente, qui avait pour but de diminuer la taille des programmes. La technique a été introduite sur des CPU 32 bits, et le jeu d'instruction utilisait des instructions codées sur 16 bits. L'exemple type est celui des Les processeurs ARM, qui incorporent un jeu d'instruction compact appelé ''thumb'', que nous allons détailler dans ce qui suit. Le mode ''thumb'' était présent sur les processeurs ARM 32 bits, mais il a été conservé lors du passage au 64 bits. Le ''thumb'' encode les instructions sur 16 bits, soit deux fois moins que l'ARM 32 bits normal. Il ne s'agissait pas d'un jeu d'instruction totalement séparé de l'ARM normal, mais d'un sous-ensemble de l'ARM normal, utilisant un encodage différent. Concrètement, toute instruction ''thumb'' 16 bits a un équivalent en ARM 32 bits. Par contre, la réciproque n'est pas vraie. Et l'encodage des deux instructions n'est pas le même, ce qui est évident vu que les instructions ''thumb'' sont encodées sur 16 bits et les équivalentes sur 32. Les instructions ''thumb'' ont des limitations que les instructions ARM 32 bits n'ont pas. Par exemple, les instructions ''thumb'' ont un accès restreint aux registres de status et du co-processeur. De plus, la majorité des instructions ''thumb'' n'ont accès qu'à la moitié des registres du processeur. Les instructions arithmétiques et les décalages sont séparés en mode ''thumb'', alors que chaque instructions arithmétiques peut ajouter un décalage à une opérande en mode ARM 32 bits. Les instructions pour le co-processeur ne sont pas disponibles en ''thumb''. Autre exemple : la gestion des instructions à prédicats. L'ARM 32 bits normal utilise la prédication totale, à savoir que toutes les instructions s'exécutent ou non selon la valeur d'un résultat de comparaison, stocké dans un registre de prédicat. Toute instruction encode donc quelques bits pour choisir ce registre. Cela est supprimé avec ''thumb''. A part quelques branchements, les instructions ne sont pas prédicatées et s'exécutent inconditionnellement. Les deux jeux d'instruction ont un mode d'exécution chacun, ce qui veut dire que le processeur est configuré pour fonctionner en mode ''thumb'' ou en mode ARM 32 bits. En clair, on ne peut pas mélanger les deux jeu d'instructions en même temps. La raison est qu'une même suite de bit correspondait soit à une instruction ARM normale, soit à deux instructions thumbs. Pour faire la différence, il faut préciser dans quel mode d'exécution le CPU est : mode ARM ou mode ''thumb''. Le processeur interprète les instructions correctement en tenant compte de ce mode. Niveau implémentation, la gestion des instructions ''thumb'' est assez simple : le processeur convertit les instructions ''thumb'' en leur équivalent ARM 32 bits, juste après les avoir chargées. Une seconde version du jeu d'instruction ''thumb'' a vu le jour, sur les processeurs ARMv6T2, avec ''thumb-2''. Elle avait pour particularité d'utiliser des instructions de taille variable. Rien d'aussi développé que ce qu'on a sur les CPU CISC, comme les CPU x86. Les instructions ARM ''thumb-2'' faisaient soit 16 bits, soit 32 bits. Concrètement, les instructions 16 bits de ''thumb-1'' étaient reprises à l'identique, ''thumb-2'' se contentait d'ajouter des instructions en plus codées sur 32 bits. Les instructions ajoutées étaient des instruction mémoire, des instructions de configuration du CPU, des instructions de manipulation de bit, et quelques autres. Un ajout particulièrement notable est l'ajout d'une instruction IT, qui active l’exécution conditionnelle des instructions suivantes. L'instruction teste une condition et calcule un prédicat, qui est ensuite utilisé pour décider de l'exécution des instructions suivantes. L'exécution conditionnelle marche jusqu'à 4 instructions consécutives. Le nombre exact est configurable et va de 1 à 4 instructions consécutives après l'instruction IT. La condition est configurable pour les 1 à 4 instructions : chaque instruction peut s'exécuter si la condition est vraie, ou au contraire fausse. Un masque de 4 bits décide de quelle condition utiliser pour chaque instruction. Il y a un bit par instruction, qui vaut 1 si la condition doit être vérifiée, 0 si on veut la condition inverse. Il faut noter que quelques processeurs ARM implémentent uniquement le jeu d'instruction ''thumb-1/2'', pas l'ARM 32 bits normal. Ce sont des processeurs faible performance, à destination de systèmes embarqués, qui ont besoin d'une densité de code correcte, comme les CPU ARM Cortex série M. : Il est arrivé que certains CPU ARM en supportent trois : le jeu d'instruction ARM 32 bits classique, le mode ''Thumb'' qu'on décrira plus bas, et une extension nommée Jazelle JBX qui accélérait les programmes écrits en Java (elle exécutait du ''bytecode'' JAVA sur le processeur, nous détaillerons cela dans un autre chapitre). Les CPU ARM n'étaient pas les seuls à utiliser la même technique. Les CPU SuperH d'Hitachi ont été les premiers à implémenter cette technique, avant ARM. Lors du passage de 16 à 32 bits, ces CPU Hitachi ont ajouté un jeu d'instruction de 16 bits à leurs CPU 32 bits, très similaire au jeu d'instruction 16 bits d'avant. Et c'est ce jeu d'instruction compact qui a servi d'inspiration à ARM. Les CPU MIPS ont tenté la même approche, avec l'inclusion du jeu d’instruction MIPS16e, de 16 bits lui aussi. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=L'encodage des instructions | prevText=L'encodage des instructions | next=La pile d'appel et les fonctions | nextText=La pile d'appel et les fonctions }} </noinclude> l5zmrbnnmtgnsv5j5azoltby48oqicw Cosmologie/L'univers observable 0 69553 774186 772982 2026-10-05T08:06:59Z ~2026-51373-26 124609 définit -> défini 774186 wikitext text/x-wiki L''''univers observable''' est la portion de l'univers que nous pouvons observer, compte tenu de la limite de la vitesse de la lumière. Des objets situés très loin ne peuvent pas être vus pour une raison très simple : la lumière qu'ils émettent n'a pas eu le temps de nous parvenir. La distance maximale à laquelle nous pouvons voir des objets (sans tenir compte d'éventuelles limitations techniques) dépend de l'âge de l'univers. S'il faut un temps supérieur à l'âge de l'univers pour nous parvenir, il nous est actuellement impossible de les voir, ce qui n'est pas le cas pour des objets situés plus près. Cette distance maximale est donc le rayon de l'univers observable. L'ensemble des points situés à la distance maximale des objets observables, à savoir la surface de l'univers observable, porte un nom : c'est l''''horizon cosmologique'''. ==Le rayon de Hubble== On pourrait croire que le rayon de Hubble est une bonne estimation de la taille de l'univers observable. Pour rappel, ce rayon de Hubble est définit à partir du facteur de Hubble comme suit : : <math>R_H(t) = c \cdot T_H</math>, avec c la vitesse de la lumière et <math>T_H</math> le temps de Hubble définit par <math>T_H = \frac{1}{H}</math>. Il s'agit de la distance parcourue par la lumière pendant le temps de Hubble, lui-même défini comme l'inverse du facteur de Hubble. Mais le rayon de Hubble n'est malheureusement pas une bonne estimation du rayon de l'univers observable, pas plus que le temps de Hubble ne donne l'âge de l'univers. En effet, le rayon de Hubble n'est en rien la limite de l'univers observable : on voit des objets sont situés au-delà et on peut calculer leur ''redshift'' ou tout autre mesure pertinente. Pourtant, sa définition semble indiquer que le rayon de Hubble soit la limite de l'univers observable. Par définition, tout ce qui est au-delà du rayon de Hubble va plus vite que la lumière. Donc la lumière émise au-delà de ce rayon n'est pas censée nous atteindre. Sauf que ce raisonnement par du principe que le rayon de Hubble est stable. Or, rappelez-vous l'équation du chapitre précédent, qui dit comment évolue le rayon de Hubble : : <math>\frac{d}{dt} R_H(t) = c \cdot (q + 1)</math> Pour notre univers, tout semble indiquer qu'il augmente avec le temps. Et cela explique pourquoi on peut voir des objets qui se déplacent "plus vite que la lumière", dans le sens où l'expansion les éloignait de nous plus vite que la lumière. La raison est que, vu que le rayon de Hubble grandit, certains photons hors du rayon de Hubble finissent par y rentrer. Ils sont en quelque sorte rattrapés par le rayon de Hubble et nous finirons donc par les voir une fois arrivés sur Terre. Par contre, les objets qui ont émis cette lumière s'éloignent bien de nous plus vite que la lumière et restent au-delà du rayon de Hubble. ==Le rayon de l'univers observable== Le calcul du rayon cosmologique actuel est assez simple sur le principe, mais compliqué en pratique. Une méthode assez simple se base sur la ''vitesse d'éloignement de l'horizon'', à savoir la vitesse à laquelle l'horizon cosmologique s'éloigne de nous. Une fois cette vitesse connue, il suffit de l'intégrer sur l'âge de l'univers. La vitesse d'éloignement du rayon observable est définie par : : <math>v(t) = \frac{d}{dt} r(t)</math>, avec <math>r(t)</math> le rayon de l'univers à l'instant t. On réutilise l'équation du chapitre précédent, qui donne la vitesse en fonction de la distance: <math>v(t) = v(t_0) a(t) + x(t_0) \cdot H(t)</math>. Dans le cas de l'horizon cosmologique, la distance est égale au rayon de l'univers et la vitesse locale est égale à la vitesse de la lumière, ce qui donne : : <math> \frac{d}{dt} r(t) = c + H(t) \times r(t)</math> On peut reformuler le tout en divisant par <math>r(t)</math>, ce qui donne : : <math> \frac{r'(t)}{r(t)} = \frac{c}{r(t)} + H(t)</math> On peut alors intégrer cette expression sur l'âge de l'univers pour obtenir le rayon de l'horizon cosmologique, du moins dans certaines conditions. Faire les calculs demande de connaître la loi d'évolution du facteur de Hubble <math>H(t) = ...</math>. Mais dans les faits, elle nous est inconnue et on n'en a que quelques bribes. La théorie ne nous est pas d'un grand secours et le seul cas que l'on peut calculer simplement est celui où le facteur de Hubble est constant. En général, les modèles cosmologiques les plus simples supposent que le facteur d'échelle suit une loi de puissance, de type <math>a(t) = a_0 \cdot (t/t_0)^{\beta}</math>, ce qui fait que l'équation peut se résoudre avec quelques développements analytiques. Mais des modèles plus réalistes ne suivent pas vraiment une loi de puissance, ce qui complique les calculs. Pour nous éviter de longs calculs fastidieux, nous allons étudier le cas général, en utilisant quelques raisonnements astucieux. ==Le rayon comobile de l'univers observable== Une autre façon de faire les calculs est de passer par l'intermédiaire du rayon comobile. Pour rappel, ce rayon comobile est le rayon corrigé de l'influence du facteur d'échelle (et donc de l'expansion). Il vaut, par définition : <math>r(t) \over a(t)</math>. La vitesse comobile est la dérivée de ce rayon comobile, qui est égale à : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)'}{a(t)} - \frac{r(t) \cdot a(t)'}{a(t)^2} = \frac{r(t)'}{a(t)} - H(t) \frac{r(t)}{a(t)}</math> On peut alors factoriser le rayon comobile <math>r(t) \over a(t)</math> : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)}{a(t)} \left[ \frac{r(t)'}{a(t)} \frac{a(t)}{r(t)} - H(t) \right]</math> On simplifie : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)}{a(t)} \left[ \frac{r(t)'}{r(t)} - H(t) \right]</math> En injectant l'équation <math> \frac{r(t)'}{r(t)} = \frac{c}{r(t)} + H(t) </math> dans la précédente, on a : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)}{a(t)} \left[ \frac{c}{r(t)} + H(t)-H(t) \right]</math> En développant, on trouve : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{c}{a(t)}</math> On voit que la dérivée est égale à ce qu'on appelle la vitesse comobile de la lumière. Par définition, la vitesse de la lumière est de <math>c = 299 792 , 458 Km/S</math>, mais il s'agit d'une vitesse propre. On peut calculer sa vitesse comobile en divisant par le facteur d'échelle, ce qui n'est autre que le premier terme de l'équation précédente. <math> c_{com} = {c \over a} </math>. En intégrant l'équation précédente sur l'âge de l'univers, on a la distance comobile de l'horizon, celle à laquelle se situait l'horizon cosmologique quand la lumière de l'horizon a été émise. On se retrouve alors avec une équation très générale, qui marche même quand le facteur de Hubble est variable. Seule la vitesse comobile de la lumière devant être prise en compte. On trouve alors que le rayon comobile se calcule avec la formule suivante, avec <math>t_u</math> l'âge de l'univers : : <math> \frac{r(t)}{a(t)} = \int_{0}^{t_u} {c \over a(t)} dt = c \int_{0}^{t_u} {dt \over a(t)}</math> Il est possible de simplifier fortement la formule précédente en faisant intervenir une variable particulière, appelée le '''temps conforme''', définie par <math>\eta = \frac{dt}{a(t)}</math>. On peut voir ce temps conforme comme l'équivalent de la distance comobile, mais pour les durées. Il n'a pas vraiment d'interprétation physique digne de ce nom et sert plus d'intermédiaire pour les calculs. Avec le temps conforme, la formule précédente devient alors : : <math> \frac{r(t)}{a(t)} = c \cdot (\eta_0 - \eta_{t_u})</math> : ''On peut voir la formule précédente comme la généralisation de la formule <math>D = c \cdot \Delta T</math>, mais où le temps est remplacé par le temps conforme.'' ==Le rayon actuel de l'univers observable== On peut obtenir la distance propre par un calcul très simple, à partir de la distance comobile. Le passage de la distance comobile à la distance propre se fait simplement en multipliant par <math>a(t)</math>. On obtient alors la distance propre suivante : : <math>r(t) = a(t) \cdot \int_{0}^{t_u} {c \over a(t)} dt</math> Dans l'équation précédente, on peut factoriser la vitesse de la lumière, ce qui donne : : <math>r(t) = c \cdot \left[ a(t) \cdot \int_{0}^{t} {1 \over a(t_i)} dt_i \right]</math> : ''Comme pour le rayon comobile, on peut voir la formule précédente comme la généralisation de la formule <math>D = c \cdot \Delta T</math>, mais où la durée <math>\Delta T</math> est remplacée par la valeur temporelle <math>\left[ a(t) \cdot \int_{0}^{t} {1 \over a(t_i)} dt_i \right]</math>.'' Les intégrales précédentes ne sont pas solubles telles quelles. Il faut préciser comment le facteur d'échelle évolue avec le temps, sans quoi les intégrales ne peuvent pas être calculées exactement. Manque de chance, la loi d'évolution du facteur d'échelle nous est inconnue. Nous sommes obligés de postuler des fonctions particulières, en espérant qu'elles collent au mieux aux observations. Nous ferons cela plus en détail dans le chapitre sur les modèles cosmologiques. {{NavChapitre | book=Cosmologie | prev=Le facteur de Hubble | prevText=Le facteur de Hubble | next=Une introduction aux modèles cosmologiques | nextText=Une introduction aux modèles cosmologiques }} [[Catégorie:Cosmologie (livre)|*]] oij36w8ewt1y76mtazmp5z956437cdy 774187 774186 2026-10-05T08:09:07Z ~2026-51373-26 124609 /* Le rayon de Hubble */ est définit -> est défini 774187 wikitext text/x-wiki L''''univers observable''' est la portion de l'univers que nous pouvons observer, compte tenu de la limite de la vitesse de la lumière. Des objets situés très loin ne peuvent pas être vus pour une raison très simple : la lumière qu'ils émettent n'a pas eu le temps de nous parvenir. La distance maximale à laquelle nous pouvons voir des objets (sans tenir compte d'éventuelles limitations techniques) dépend de l'âge de l'univers. S'il faut un temps supérieur à l'âge de l'univers pour nous parvenir, il nous est actuellement impossible de les voir, ce qui n'est pas le cas pour des objets situés plus près. Cette distance maximale est donc le rayon de l'univers observable. L'ensemble des points situés à la distance maximale des objets observables, à savoir la surface de l'univers observable, porte un nom : c'est l''''horizon cosmologique'''. ==Le rayon de Hubble== On pourrait croire que le rayon de Hubble est une bonne estimation de la taille de l'univers observable. Pour rappel, ce rayon de Hubble est défini à partir du facteur de Hubble comme suit : : <math>R_H(t) = c \cdot T_H</math>, avec c la vitesse de la lumière et <math>T_H</math> le temps de Hubble définit par <math>T_H = \frac{1}{H}</math>. Il s'agit de la distance parcourue par la lumière pendant le temps de Hubble, lui-même défini comme l'inverse du facteur de Hubble. Mais le rayon de Hubble n'est malheureusement pas une bonne estimation du rayon de l'univers observable, pas plus que le temps de Hubble ne donne l'âge de l'univers. En effet, le rayon de Hubble n'est en rien la limite de l'univers observable : on voit des objets sont situés au-delà et on peut calculer leur ''redshift'' ou tout autre mesure pertinente. Pourtant, sa définition semble indiquer que le rayon de Hubble soit la limite de l'univers observable. Par définition, tout ce qui est au-delà du rayon de Hubble va plus vite que la lumière. Donc la lumière émise au-delà de ce rayon n'est pas censée nous atteindre. Sauf que ce raisonnement par du principe que le rayon de Hubble est stable. Or, rappelez-vous l'équation du chapitre précédent, qui dit comment évolue le rayon de Hubble : : <math>\frac{d}{dt} R_H(t) = c \cdot (q + 1)</math> Pour notre univers, tout semble indiquer qu'il augmente avec le temps. Et cela explique pourquoi on peut voir des objets qui se déplacent "plus vite que la lumière", dans le sens où l'expansion les éloignait de nous plus vite que la lumière. La raison est que, vu que le rayon de Hubble grandit, certains photons hors du rayon de Hubble finissent par y rentrer. Ils sont en quelque sorte rattrapés par le rayon de Hubble et nous finirons donc par les voir une fois arrivés sur Terre. Par contre, les objets qui ont émis cette lumière s'éloignent bien de nous plus vite que la lumière et restent au-delà du rayon de Hubble. ==Le rayon de l'univers observable== Le calcul du rayon cosmologique actuel est assez simple sur le principe, mais compliqué en pratique. Une méthode assez simple se base sur la ''vitesse d'éloignement de l'horizon'', à savoir la vitesse à laquelle l'horizon cosmologique s'éloigne de nous. Une fois cette vitesse connue, il suffit de l'intégrer sur l'âge de l'univers. La vitesse d'éloignement du rayon observable est définie par : : <math>v(t) = \frac{d}{dt} r(t)</math>, avec <math>r(t)</math> le rayon de l'univers à l'instant t. On réutilise l'équation du chapitre précédent, qui donne la vitesse en fonction de la distance: <math>v(t) = v(t_0) a(t) + x(t_0) \cdot H(t)</math>. Dans le cas de l'horizon cosmologique, la distance est égale au rayon de l'univers et la vitesse locale est égale à la vitesse de la lumière, ce qui donne : : <math> \frac{d}{dt} r(t) = c + H(t) \times r(t)</math> On peut reformuler le tout en divisant par <math>r(t)</math>, ce qui donne : : <math> \frac{r'(t)}{r(t)} = \frac{c}{r(t)} + H(t)</math> On peut alors intégrer cette expression sur l'âge de l'univers pour obtenir le rayon de l'horizon cosmologique, du moins dans certaines conditions. Faire les calculs demande de connaître la loi d'évolution du facteur de Hubble <math>H(t) = ...</math>. Mais dans les faits, elle nous est inconnue et on n'en a que quelques bribes. La théorie ne nous est pas d'un grand secours et le seul cas que l'on peut calculer simplement est celui où le facteur de Hubble est constant. En général, les modèles cosmologiques les plus simples supposent que le facteur d'échelle suit une loi de puissance, de type <math>a(t) = a_0 \cdot (t/t_0)^{\beta}</math>, ce qui fait que l'équation peut se résoudre avec quelques développements analytiques. Mais des modèles plus réalistes ne suivent pas vraiment une loi de puissance, ce qui complique les calculs. Pour nous éviter de longs calculs fastidieux, nous allons étudier le cas général, en utilisant quelques raisonnements astucieux. ==Le rayon comobile de l'univers observable== Une autre façon de faire les calculs est de passer par l'intermédiaire du rayon comobile. Pour rappel, ce rayon comobile est le rayon corrigé de l'influence du facteur d'échelle (et donc de l'expansion). Il vaut, par définition : <math>r(t) \over a(t)</math>. La vitesse comobile est la dérivée de ce rayon comobile, qui est égale à : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)'}{a(t)} - \frac{r(t) \cdot a(t)'}{a(t)^2} = \frac{r(t)'}{a(t)} - H(t) \frac{r(t)}{a(t)}</math> On peut alors factoriser le rayon comobile <math>r(t) \over a(t)</math> : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)}{a(t)} \left[ \frac{r(t)'}{a(t)} \frac{a(t)}{r(t)} - H(t) \right]</math> On simplifie : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)}{a(t)} \left[ \frac{r(t)'}{r(t)} - H(t) \right]</math> En injectant l'équation <math> \frac{r(t)'}{r(t)} = \frac{c}{r(t)} + H(t) </math> dans la précédente, on a : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)}{a(t)} \left[ \frac{c}{r(t)} + H(t)-H(t) \right]</math> En développant, on trouve : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{c}{a(t)}</math> On voit que la dérivée est égale à ce qu'on appelle la vitesse comobile de la lumière. Par définition, la vitesse de la lumière est de <math>c = 299 792 , 458 Km/S</math>, mais il s'agit d'une vitesse propre. On peut calculer sa vitesse comobile en divisant par le facteur d'échelle, ce qui n'est autre que le premier terme de l'équation précédente. <math> c_{com} = {c \over a} </math>. En intégrant l'équation précédente sur l'âge de l'univers, on a la distance comobile de l'horizon, celle à laquelle se situait l'horizon cosmologique quand la lumière de l'horizon a été émise. On se retrouve alors avec une équation très générale, qui marche même quand le facteur de Hubble est variable. Seule la vitesse comobile de la lumière devant être prise en compte. On trouve alors que le rayon comobile se calcule avec la formule suivante, avec <math>t_u</math> l'âge de l'univers : : <math> \frac{r(t)}{a(t)} = \int_{0}^{t_u} {c \over a(t)} dt = c \int_{0}^{t_u} {dt \over a(t)}</math> Il est possible de simplifier fortement la formule précédente en faisant intervenir une variable particulière, appelée le '''temps conforme''', définie par <math>\eta = \frac{dt}{a(t)}</math>. On peut voir ce temps conforme comme l'équivalent de la distance comobile, mais pour les durées. Il n'a pas vraiment d'interprétation physique digne de ce nom et sert plus d'intermédiaire pour les calculs. Avec le temps conforme, la formule précédente devient alors : : <math> \frac{r(t)}{a(t)} = c \cdot (\eta_0 - \eta_{t_u})</math> : ''On peut voir la formule précédente comme la généralisation de la formule <math>D = c \cdot \Delta T</math>, mais où le temps est remplacé par le temps conforme.'' ==Le rayon actuel de l'univers observable== On peut obtenir la distance propre par un calcul très simple, à partir de la distance comobile. Le passage de la distance comobile à la distance propre se fait simplement en multipliant par <math>a(t)</math>. On obtient alors la distance propre suivante : : <math>r(t) = a(t) \cdot \int_{0}^{t_u} {c \over a(t)} dt</math> Dans l'équation précédente, on peut factoriser la vitesse de la lumière, ce qui donne : : <math>r(t) = c \cdot \left[ a(t) \cdot \int_{0}^{t} {1 \over a(t_i)} dt_i \right]</math> : ''Comme pour le rayon comobile, on peut voir la formule précédente comme la généralisation de la formule <math>D = c \cdot \Delta T</math>, mais où la durée <math>\Delta T</math> est remplacée par la valeur temporelle <math>\left[ a(t) \cdot \int_{0}^{t} {1 \over a(t_i)} dt_i \right]</math>.'' Les intégrales précédentes ne sont pas solubles telles quelles. Il faut préciser comment le facteur d'échelle évolue avec le temps, sans quoi les intégrales ne peuvent pas être calculées exactement. Manque de chance, la loi d'évolution du facteur d'échelle nous est inconnue. Nous sommes obligés de postuler des fonctions particulières, en espérant qu'elles collent au mieux aux observations. Nous ferons cela plus en détail dans le chapitre sur les modèles cosmologiques. {{NavChapitre | book=Cosmologie | prev=Le facteur de Hubble | prevText=Le facteur de Hubble | next=Une introduction aux modèles cosmologiques | nextText=Une introduction aux modèles cosmologiques }} [[Catégorie:Cosmologie (livre)|*]] q9uid9pao5zzx6rvy7w77j0jhmwfpl1 774188 774187 2026-10-05T08:12:55Z ~2026-51373-26 124609 1) définit -> défini 2) sont situés -> situés 774188 wikitext text/x-wiki L''''univers observable''' est la portion de l'univers que nous pouvons observer, compte tenu de la limite de la vitesse de la lumière. Des objets situés très loin ne peuvent pas être vus pour une raison très simple : la lumière qu'ils émettent n'a pas eu le temps de nous parvenir. La distance maximale à laquelle nous pouvons voir des objets (sans tenir compte d'éventuelles limitations techniques) dépend de l'âge de l'univers. S'il faut un temps supérieur à l'âge de l'univers pour nous parvenir, il nous est actuellement impossible de les voir, ce qui n'est pas le cas pour des objets situés plus près. Cette distance maximale est donc le rayon de l'univers observable. L'ensemble des points situés à la distance maximale des objets observables, à savoir la surface de l'univers observable, porte un nom : c'est l''''horizon cosmologique'''. ==Le rayon de Hubble== On pourrait croire que le rayon de Hubble est une bonne estimation de la taille de l'univers observable. Pour rappel, ce rayon de Hubble est défini à partir du facteur de Hubble comme suit : : <math>R_H(t) = c \cdot T_H</math>, avec c la vitesse de la lumière et <math>T_H</math> le temps de Hubble défini par <math>T_H = \frac{1}{H}</math>. Il s'agit de la distance parcourue par la lumière pendant le temps de Hubble, lui-même défini comme l'inverse du facteur de Hubble. Mais le rayon de Hubble n'est malheureusement pas une bonne estimation du rayon de l'univers observable, pas plus que le temps de Hubble ne donne l'âge de l'univers. En effet, le rayon de Hubble n'est en rien la limite de l'univers observable : on voit des objets situés au-delà et on peut calculer leur ''redshift'' ou tout autre mesure pertinente. Pourtant, sa définition semble indiquer que le rayon de Hubble soit la limite de l'univers observable. Par définition, tout ce qui est au-delà du rayon de Hubble va plus vite que la lumière. Donc la lumière émise au-delà de ce rayon n'est pas censée nous atteindre. Sauf que ce raisonnement par du principe que le rayon de Hubble est stable. Or, rappelez-vous l'équation du chapitre précédent, qui dit comment évolue le rayon de Hubble : : <math>\frac{d}{dt} R_H(t) = c \cdot (q + 1)</math> Pour notre univers, tout semble indiquer qu'il augmente avec le temps. Et cela explique pourquoi on peut voir des objets qui se déplacent "plus vite que la lumière", dans le sens où l'expansion les éloignait de nous plus vite que la lumière. La raison est que, vu que le rayon de Hubble grandit, certains photons hors du rayon de Hubble finissent par y rentrer. Ils sont en quelque sorte rattrapés par le rayon de Hubble et nous finirons donc par les voir une fois arrivés sur Terre. Par contre, les objets qui ont émis cette lumière s'éloignent bien de nous plus vite que la lumière et restent au-delà du rayon de Hubble. ==Le rayon de l'univers observable== Le calcul du rayon cosmologique actuel est assez simple sur le principe, mais compliqué en pratique. Une méthode assez simple se base sur la ''vitesse d'éloignement de l'horizon'', à savoir la vitesse à laquelle l'horizon cosmologique s'éloigne de nous. Une fois cette vitesse connue, il suffit de l'intégrer sur l'âge de l'univers. La vitesse d'éloignement du rayon observable est définie par : : <math>v(t) = \frac{d}{dt} r(t)</math>, avec <math>r(t)</math> le rayon de l'univers à l'instant t. On réutilise l'équation du chapitre précédent, qui donne la vitesse en fonction de la distance: <math>v(t) = v(t_0) a(t) + x(t_0) \cdot H(t)</math>. Dans le cas de l'horizon cosmologique, la distance est égale au rayon de l'univers et la vitesse locale est égale à la vitesse de la lumière, ce qui donne : : <math> \frac{d}{dt} r(t) = c + H(t) \times r(t)</math> On peut reformuler le tout en divisant par <math>r(t)</math>, ce qui donne : : <math> \frac{r'(t)}{r(t)} = \frac{c}{r(t)} + H(t)</math> On peut alors intégrer cette expression sur l'âge de l'univers pour obtenir le rayon de l'horizon cosmologique, du moins dans certaines conditions. Faire les calculs demande de connaître la loi d'évolution du facteur de Hubble <math>H(t) = ...</math>. Mais dans les faits, elle nous est inconnue et on n'en a que quelques bribes. La théorie ne nous est pas d'un grand secours et le seul cas que l'on peut calculer simplement est celui où le facteur de Hubble est constant. En général, les modèles cosmologiques les plus simples supposent que le facteur d'échelle suit une loi de puissance, de type <math>a(t) = a_0 \cdot (t/t_0)^{\beta}</math>, ce qui fait que l'équation peut se résoudre avec quelques développements analytiques. Mais des modèles plus réalistes ne suivent pas vraiment une loi de puissance, ce qui complique les calculs. Pour nous éviter de longs calculs fastidieux, nous allons étudier le cas général, en utilisant quelques raisonnements astucieux. ==Le rayon comobile de l'univers observable== Une autre façon de faire les calculs est de passer par l'intermédiaire du rayon comobile. Pour rappel, ce rayon comobile est le rayon corrigé de l'influence du facteur d'échelle (et donc de l'expansion). Il vaut, par définition : <math>r(t) \over a(t)</math>. La vitesse comobile est la dérivée de ce rayon comobile, qui est égale à : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)'}{a(t)} - \frac{r(t) \cdot a(t)'}{a(t)^2} = \frac{r(t)'}{a(t)} - H(t) \frac{r(t)}{a(t)}</math> On peut alors factoriser le rayon comobile <math>r(t) \over a(t)</math> : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)}{a(t)} \left[ \frac{r(t)'}{a(t)} \frac{a(t)}{r(t)} - H(t) \right]</math> On simplifie : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)}{a(t)} \left[ \frac{r(t)'}{r(t)} - H(t) \right]</math> En injectant l'équation <math> \frac{r(t)'}{r(t)} = \frac{c}{r(t)} + H(t) </math> dans la précédente, on a : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)}{a(t)} \left[ \frac{c}{r(t)} + H(t)-H(t) \right]</math> En développant, on trouve : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{c}{a(t)}</math> On voit que la dérivée est égale à ce qu'on appelle la vitesse comobile de la lumière. Par définition, la vitesse de la lumière est de <math>c = 299 792 , 458 Km/S</math>, mais il s'agit d'une vitesse propre. On peut calculer sa vitesse comobile en divisant par le facteur d'échelle, ce qui n'est autre que le premier terme de l'équation précédente. <math> c_{com} = {c \over a} </math>. En intégrant l'équation précédente sur l'âge de l'univers, on a la distance comobile de l'horizon, celle à laquelle se situait l'horizon cosmologique quand la lumière de l'horizon a été émise. On se retrouve alors avec une équation très générale, qui marche même quand le facteur de Hubble est variable. Seule la vitesse comobile de la lumière devant être prise en compte. On trouve alors que le rayon comobile se calcule avec la formule suivante, avec <math>t_u</math> l'âge de l'univers : : <math> \frac{r(t)}{a(t)} = \int_{0}^{t_u} {c \over a(t)} dt = c \int_{0}^{t_u} {dt \over a(t)}</math> Il est possible de simplifier fortement la formule précédente en faisant intervenir une variable particulière, appelée le '''temps conforme''', définie par <math>\eta = \frac{dt}{a(t)}</math>. On peut voir ce temps conforme comme l'équivalent de la distance comobile, mais pour les durées. Il n'a pas vraiment d'interprétation physique digne de ce nom et sert plus d'intermédiaire pour les calculs. Avec le temps conforme, la formule précédente devient alors : : <math> \frac{r(t)}{a(t)} = c \cdot (\eta_0 - \eta_{t_u})</math> : ''On peut voir la formule précédente comme la généralisation de la formule <math>D = c \cdot \Delta T</math>, mais où le temps est remplacé par le temps conforme.'' ==Le rayon actuel de l'univers observable== On peut obtenir la distance propre par un calcul très simple, à partir de la distance comobile. Le passage de la distance comobile à la distance propre se fait simplement en multipliant par <math>a(t)</math>. On obtient alors la distance propre suivante : : <math>r(t) = a(t) \cdot \int_{0}^{t_u} {c \over a(t)} dt</math> Dans l'équation précédente, on peut factoriser la vitesse de la lumière, ce qui donne : : <math>r(t) = c \cdot \left[ a(t) \cdot \int_{0}^{t} {1 \over a(t_i)} dt_i \right]</math> : ''Comme pour le rayon comobile, on peut voir la formule précédente comme la généralisation de la formule <math>D = c \cdot \Delta T</math>, mais où la durée <math>\Delta T</math> est remplacée par la valeur temporelle <math>\left[ a(t) \cdot \int_{0}^{t} {1 \over a(t_i)} dt_i \right]</math>.'' Les intégrales précédentes ne sont pas solubles telles quelles. Il faut préciser comment le facteur d'échelle évolue avec le temps, sans quoi les intégrales ne peuvent pas être calculées exactement. Manque de chance, la loi d'évolution du facteur d'échelle nous est inconnue. Nous sommes obligés de postuler des fonctions particulières, en espérant qu'elles collent au mieux aux observations. Nous ferons cela plus en détail dans le chapitre sur les modèles cosmologiques. {{NavChapitre | book=Cosmologie | prev=Le facteur de Hubble | prevText=Le facteur de Hubble | next=Une introduction aux modèles cosmologiques | nextText=Une introduction aux modèles cosmologiques }} [[Catégorie:Cosmologie (livre)|*]] m9sxeioldu87j52pbzhy7o72nnwgibh 774189 774188 2026-10-05T08:14:21Z ~2026-51373-26 124609 /* Le rayon de Hubble */ par du principe -> part du principe 774189 wikitext text/x-wiki L''''univers observable''' est la portion de l'univers que nous pouvons observer, compte tenu de la limite de la vitesse de la lumière. Des objets situés très loin ne peuvent pas être vus pour une raison très simple : la lumière qu'ils émettent n'a pas eu le temps de nous parvenir. La distance maximale à laquelle nous pouvons voir des objets (sans tenir compte d'éventuelles limitations techniques) dépend de l'âge de l'univers. S'il faut un temps supérieur à l'âge de l'univers pour nous parvenir, il nous est actuellement impossible de les voir, ce qui n'est pas le cas pour des objets situés plus près. Cette distance maximale est donc le rayon de l'univers observable. L'ensemble des points situés à la distance maximale des objets observables, à savoir la surface de l'univers observable, porte un nom : c'est l''''horizon cosmologique'''. ==Le rayon de Hubble== On pourrait croire que le rayon de Hubble est une bonne estimation de la taille de l'univers observable. Pour rappel, ce rayon de Hubble est défini à partir du facteur de Hubble comme suit : : <math>R_H(t) = c \cdot T_H</math>, avec c la vitesse de la lumière et <math>T_H</math> le temps de Hubble défini par <math>T_H = \frac{1}{H}</math>. Il s'agit de la distance parcourue par la lumière pendant le temps de Hubble, lui-même défini comme l'inverse du facteur de Hubble. Mais le rayon de Hubble n'est malheureusement pas une bonne estimation du rayon de l'univers observable, pas plus que le temps de Hubble ne donne l'âge de l'univers. En effet, le rayon de Hubble n'est en rien la limite de l'univers observable : on voit des objets situés au-delà et on peut calculer leur ''redshift'' ou tout autre mesure pertinente. Pourtant, sa définition semble indiquer que le rayon de Hubble soit la limite de l'univers observable. Par définition, tout ce qui est au-delà du rayon de Hubble va plus vite que la lumière. Donc la lumière émise au-delà de ce rayon n'est pas censée nous atteindre. Sauf que ce raisonnement part du principe que le rayon de Hubble est stable. Or, rappelez-vous l'équation du chapitre précédent, qui dit comment évolue le rayon de Hubble : : <math>\frac{d}{dt} R_H(t) = c \cdot (q + 1)</math> Pour notre univers, tout semble indiquer qu'il augmente avec le temps. Et cela explique pourquoi on peut voir des objets qui se déplacent "plus vite que la lumière", dans le sens où l'expansion les éloignait de nous plus vite que la lumière. La raison est que, vu que le rayon de Hubble grandit, certains photons hors du rayon de Hubble finissent par y rentrer. Ils sont en quelque sorte rattrapés par le rayon de Hubble et nous finirons donc par les voir une fois arrivés sur Terre. Par contre, les objets qui ont émis cette lumière s'éloignent bien de nous plus vite que la lumière et restent au-delà du rayon de Hubble. ==Le rayon de l'univers observable== Le calcul du rayon cosmologique actuel est assez simple sur le principe, mais compliqué en pratique. Une méthode assez simple se base sur la ''vitesse d'éloignement de l'horizon'', à savoir la vitesse à laquelle l'horizon cosmologique s'éloigne de nous. Une fois cette vitesse connue, il suffit de l'intégrer sur l'âge de l'univers. La vitesse d'éloignement du rayon observable est définie par : : <math>v(t) = \frac{d}{dt} r(t)</math>, avec <math>r(t)</math> le rayon de l'univers à l'instant t. On réutilise l'équation du chapitre précédent, qui donne la vitesse en fonction de la distance: <math>v(t) = v(t_0) a(t) + x(t_0) \cdot H(t)</math>. Dans le cas de l'horizon cosmologique, la distance est égale au rayon de l'univers et la vitesse locale est égale à la vitesse de la lumière, ce qui donne : : <math> \frac{d}{dt} r(t) = c + H(t) \times r(t)</math> On peut reformuler le tout en divisant par <math>r(t)</math>, ce qui donne : : <math> \frac{r'(t)}{r(t)} = \frac{c}{r(t)} + H(t)</math> On peut alors intégrer cette expression sur l'âge de l'univers pour obtenir le rayon de l'horizon cosmologique, du moins dans certaines conditions. Faire les calculs demande de connaître la loi d'évolution du facteur de Hubble <math>H(t) = ...</math>. Mais dans les faits, elle nous est inconnue et on n'en a que quelques bribes. La théorie ne nous est pas d'un grand secours et le seul cas que l'on peut calculer simplement est celui où le facteur de Hubble est constant. En général, les modèles cosmologiques les plus simples supposent que le facteur d'échelle suit une loi de puissance, de type <math>a(t) = a_0 \cdot (t/t_0)^{\beta}</math>, ce qui fait que l'équation peut se résoudre avec quelques développements analytiques. Mais des modèles plus réalistes ne suivent pas vraiment une loi de puissance, ce qui complique les calculs. Pour nous éviter de longs calculs fastidieux, nous allons étudier le cas général, en utilisant quelques raisonnements astucieux. ==Le rayon comobile de l'univers observable== Une autre façon de faire les calculs est de passer par l'intermédiaire du rayon comobile. Pour rappel, ce rayon comobile est le rayon corrigé de l'influence du facteur d'échelle (et donc de l'expansion). Il vaut, par définition : <math>r(t) \over a(t)</math>. La vitesse comobile est la dérivée de ce rayon comobile, qui est égale à : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)'}{a(t)} - \frac{r(t) \cdot a(t)'}{a(t)^2} = \frac{r(t)'}{a(t)} - H(t) \frac{r(t)}{a(t)}</math> On peut alors factoriser le rayon comobile <math>r(t) \over a(t)</math> : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)}{a(t)} \left[ \frac{r(t)'}{a(t)} \frac{a(t)}{r(t)} - H(t) \right]</math> On simplifie : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)}{a(t)} \left[ \frac{r(t)'}{r(t)} - H(t) \right]</math> En injectant l'équation <math> \frac{r(t)'}{r(t)} = \frac{c}{r(t)} + H(t) </math> dans la précédente, on a : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{r(t)}{a(t)} \left[ \frac{c}{r(t)} + H(t)-H(t) \right]</math> En développant, on trouve : : <math>\frac{d}{dt} \left( \frac{r(t)}{a(t)} \right) = \frac{c}{a(t)}</math> On voit que la dérivée est égale à ce qu'on appelle la vitesse comobile de la lumière. Par définition, la vitesse de la lumière est de <math>c = 299 792 , 458 Km/S</math>, mais il s'agit d'une vitesse propre. On peut calculer sa vitesse comobile en divisant par le facteur d'échelle, ce qui n'est autre que le premier terme de l'équation précédente. <math> c_{com} = {c \over a} </math>. En intégrant l'équation précédente sur l'âge de l'univers, on a la distance comobile de l'horizon, celle à laquelle se situait l'horizon cosmologique quand la lumière de l'horizon a été émise. On se retrouve alors avec une équation très générale, qui marche même quand le facteur de Hubble est variable. Seule la vitesse comobile de la lumière devant être prise en compte. On trouve alors que le rayon comobile se calcule avec la formule suivante, avec <math>t_u</math> l'âge de l'univers : : <math> \frac{r(t)}{a(t)} = \int_{0}^{t_u} {c \over a(t)} dt = c \int_{0}^{t_u} {dt \over a(t)}</math> Il est possible de simplifier fortement la formule précédente en faisant intervenir une variable particulière, appelée le '''temps conforme''', définie par <math>\eta = \frac{dt}{a(t)}</math>. On peut voir ce temps conforme comme l'équivalent de la distance comobile, mais pour les durées. Il n'a pas vraiment d'interprétation physique digne de ce nom et sert plus d'intermédiaire pour les calculs. Avec le temps conforme, la formule précédente devient alors : : <math> \frac{r(t)}{a(t)} = c \cdot (\eta_0 - \eta_{t_u})</math> : ''On peut voir la formule précédente comme la généralisation de la formule <math>D = c \cdot \Delta T</math>, mais où le temps est remplacé par le temps conforme.'' ==Le rayon actuel de l'univers observable== On peut obtenir la distance propre par un calcul très simple, à partir de la distance comobile. Le passage de la distance comobile à la distance propre se fait simplement en multipliant par <math>a(t)</math>. On obtient alors la distance propre suivante : : <math>r(t) = a(t) \cdot \int_{0}^{t_u} {c \over a(t)} dt</math> Dans l'équation précédente, on peut factoriser la vitesse de la lumière, ce qui donne : : <math>r(t) = c \cdot \left[ a(t) \cdot \int_{0}^{t} {1 \over a(t_i)} dt_i \right]</math> : ''Comme pour le rayon comobile, on peut voir la formule précédente comme la généralisation de la formule <math>D = c \cdot \Delta T</math>, mais où la durée <math>\Delta T</math> est remplacée par la valeur temporelle <math>\left[ a(t) \cdot \int_{0}^{t} {1 \over a(t_i)} dt_i \right]</math>.'' Les intégrales précédentes ne sont pas solubles telles quelles. Il faut préciser comment le facteur d'échelle évolue avec le temps, sans quoi les intégrales ne peuvent pas être calculées exactement. Manque de chance, la loi d'évolution du facteur d'échelle nous est inconnue. Nous sommes obligés de postuler des fonctions particulières, en espérant qu'elles collent au mieux aux observations. Nous ferons cela plus en détail dans le chapitre sur les modèles cosmologiques. {{NavChapitre | book=Cosmologie | prev=Le facteur de Hubble | prevText=Le facteur de Hubble | next=Une introduction aux modèles cosmologiques | nextText=Une introduction aux modèles cosmologiques }} [[Catégorie:Cosmologie (livre)|*]] cia9jby0r7rpcagbq38k2gh2i4wlh2i Formation musicale/Qu'est-ce que la musique ? 0 75724 774167 771511 2026-10-04T18:28:51Z Cdang 1202 /* Voir aussi */ c'est pas sorcier 774167 wikitext text/x-wiki {{Bases de solfège}} <span style="font-size:25px;">1. Qu'est-ce que la musique ?</span> ---- La musique est l'art d'organiser les sons et les silences afin de provoquer une émotion. == Technique de production des sons == Puisqu'il faut organiser les sons, il faut pouvoir déterminer l'instant auquel ils vont se produire : la musique concerne donc initialement les sons provoqués par les humains. Il peut s'agir de sons créés par l'utilisation du seul corps : * voix ; * sifflement ; * percussions corporelles : claquer des mains ou des doigts, frapper des pieds par terre, frapper ses cuisses, sa poitrine, claquer la langue… Il peut également s'agir de sons produits des instruments : flûte, tambour, piano, violon, guitare… Les instruments de musique sont des outils, des objets créés par les humains dans un but précis (ici créer des sons) ; ils consistent à créer des vibrations de l'air, soit en faisant vibrer une surface (surface d'une percussion, caisse de résonance d'un instrument à cordes), soit en provoquant des perturbations dans un flux d'air (instruments à vent). Grâce à l'invention de l'enregistrement à la fin du {{pc|xix}}<sup>e</sup> siècle, il est possible d'utiliser des sons naturels dans des œuvres. Mais le bruit naturel environnant, par exemple lorsque l'on est en plein air, peut aussi faire partie de la musique, en tant que « fond, papier peint, tapisserie » (pour les bruits d'un court d'eau ou le sifflement du vent) ou en tant que sons aléatoires (ressac, chants d'oiseaux dont on ne peut prévoir quand ils vont survenir). L'invention du haut-parleur à la fin du {{pc|xix}}<sup>e</sup> siècle donne naissance à la musique amplifiée : les sons peuvent être captés par un microphone (ou micro) puis transmis à distance et rejoués plus fort ou plus bas ; soit transmis par un fil, le haut-parleur étant éloigné du micro (cas d'une salle de concert, mais aussi de la musique écoutée en ligne par Internet), soit transmis par les ondes radio (radio, télévision, téléphone portable, oreillette ''bluetooth'', liaison wifi, micro-HF). L'invention de l'électronique au début du {{pc|xx}}<sup>e</sup> siècle fournit de nouvelles manières de créer des sons : le son produit par le haut-parleur n'est plus la reproduction d'un son capté, mais est un son créé directement par un circuit électronique, ce qui donne le thérémine ou les ondes Martenot vers 1920. Puis le développement de l'informatique permet de définir le son à produire par un programme, ce qui donne naissance à la musique synthétisée, et en particulier au synthétiseur dans les années 1950. == Usage de la musique == La musique se retrouve dans toutes les cultures. Lorsqu'elle est pratiquée en groupe, elle rejoint les autres pratiques sociales : respecter des règles communes, partager un moment, créer quelque chose ensemble, faire partie d'un groupe… Si elle est en soi une activité, elle accompagne aussi les autres pratiques de représentation : danse, théâtre, cirque, cinéma. Elle accompagne également les cérémonies, célébrations et rites. Mais la musique provoque aussi des émotions individuellement, lorsque l'on joue ou que l'on écoute de la musique seul·e. Notons toutefois que la musique n'est pas identifiée comme une pratique explicite dans toutes les cultures. Par exemple, dans certains cas, la musique est utilisée exclusivement pour accompagner une activité, il n'y a pas forcément de mot spécifique pour désigner la musique en dehors de l'activité à laquelle elle est liée. ; Exemples de musique rituelle * {{lien web | url = https://www.dailymotion.com/video/x4vua6a | titre = <nowiki>[</nowiki>Resonances<nowiki>]</nowiki> Voices from Ancient Abbeys: Plainchant & Polyphony (Album presentation) | auteur = harmonia mundi | site = Dailymotion | date = 2017 | consulté le = 2021-01-20 }} : plain-chant (musique sacrée chrétienne) * {{lien web | url = https://www.youtube.com/watch?v=PgPSU6SqDL0 | titre = A Native American Ritual Music | auteur = Ancestral Way Music | site = YouTube | date = 2020-09-09 | consulté le = 2021-01-20 }} : ''{{lang|en|Little Ritual}}'', Douglas Spotted Eagle * {{lien web | url = https://www.youtube.com/watch?v=yHexcVpHsb4 | titre = Kenya and Tanzania Witchcraft and Ritual Music (1975) | auteur = Aeolus 13 Umbra | site = YouTube | date = 2020-02-25 | consulté le = 2021-01-20 }} * {{lien web | url = https://www.youtube.com/watch?v=kdBSTAw_cKY | titre = Ritual Music of Tibetan Buddhism | auteur = Elena O Val | site = YouTube | date = 2009-09-18 | consulté le = 2021-01-20 }} : moines bouddhistes du monastère Dip Tse Chok Ling, Dharamsala La musique a parfois une utilité pratique, en particulier dans le cas des chants de travail ''({{lang|en|working song}})''. Ces chants permettent : * de se donner du cœur à l'ouvrage ; * de garder le moral dans des conditions difficiles, par exemple dans le cas des chants de marins qui restent longtemps sans toucher terre, ou des chants de bagnards ou d'esclaves ; * de se coordonner, de synchroniser ses gestes, de rythmer le travail ; * c'est parfois une manière de mesurer le temps : la durée du chant correspond à la durée de l'opération. ; Exemples de chants de travail * {{lien web | url = https://www.youtube.com/watch?v=ATlmhXuHutw | titre = Maria Marquez — Canto del Pilon | auteur = Alexey S | site = YouTube | date = 2010-02-16 | consulté le = 2021-01-13 }} * {{lien web | url = https://www.youtube.com/watch?v=sI8NH1421-c | titre = Chant de travail (work-song) | auteur = Documents musicaux | site = YouTube | date = 2015-04-09 | consulté le = 2021-01-13 }}, extrait de ''O’ Brother, Where Art Thou?'', Joel Cohen (2000) * {{lien web | url = https://www.youtube.com/watch?v=RALnF8RtITs | titre = Velkommen hjem Statsraad Lehmkuhl | auteur = wiostan | site = YouTube | date = 2016-12-10 | consulté le = 2021-01-13 }} (chant de marins) * {{lien web | url = https://www.youtube.com/watch?v=8eVcJlqWIEQ | titre = Ghana : Les forçats d'Elmina (extrait) | auteur = ThalassaOfficiel | site = YouTube | date = 2012-08-23 | consulté le = 2021-01-14 }} * {{lien web | url = https://www.youtube.com/watch?v=xzbp6ZS6oE8 | titre = Le kulning, un chant ancestral féminin pour appeler les troupeaux - La chronique d'Aliette de Laleu | auteur = France Musique | site = YouTube | date = 2021-02-19 | consulté le = 2021-03-04 }} * {{lien web | url = https://www.youtube.com/watch?v=te3osM4q1sI | titre = Le chant des sardinières - La chronique d'Aliette de Laleu | auteur = France Musique | site = YouTube | date = 2020-11-23 | consulté le = 2021-03-04 }} Dans certains cas, on veut inscrire la production de musique dans un lieu ayant ses propres sons, par exemple un théâtre de nature ou dans les rues, le métro… Dans d'autres cas, on veut au contraire se couper de l'environnement, s'isoler des bruits extérieurs considérés comme parasites, la musique se joue alors dans une salle de concert ou de spectacle. Dans certains cas on cherche à faire participer le public, qu'il danse, tape des mains et des pieds, chante en chœur ; dans d'autres cas, la musique ne sera qu'un tapis sonore pour une autre activité, par exemple un repas, un trajet en ascenseur ou des courses au supermarché ; parfois, on voudra que le public soit attentif et discret afin de ne pas interférer avec la musique produite. ; Participation du public * {{lien web | url = https://www.youtube.com/watch?v=lWFq8uILaq4 | titre = Pourquoi la foule chante toujours juste – Feat.PV NOVA | ÉPISODE #7 | auteur = Fouloscopie | site = YouTube | date = 2019-10-23 | consulté le = 2021-01-15 }} === Musique et langage === La musique est également un moyen de communication à distance : certains sons produits peuvent se propager bien plus loin que la voix et ces sons se distinguent d'autres bruits ambiants (et en particulier sur un champ de bataille). C'est le cas par exemple des [[w:fr:Langage sifflé|langues sifflées]], des langues tambourinées (par exemple utilisation du [[w:fr:Tama|tama]] ou du tambour à lèvres en Afrique), des cors (corne ou olifant, cor postal, cor des Alpes), et des sonneries militaires (fifre, clairon, tambour d'ordonnance). ; Exemple de langues musicales * {{lien web | url = https://www.youtube.com/watch?v=R64Rl6JWYxM | titre = Dans le Béarn, on siffle l'occitan pour se parler | auteur = AFP | site = YouTube | date = 2016-07-25 | consulté le = 2021-01-13 }} * {{lien web | url = https://www.dailymotion.com/video/x5phv35 | titre = Une langue entièrement sifflée : le silbo gomero | auteur = Buzz moi ça ! | site = Dailymotion | date = 2017 | consulté la = 2021-01-13 }} * {{lien web | url = https://www.youtube.com/watch?v=UbVz_okyY3g | titre = Le langage sifflé de l'île de la Gomera (îles Canaries), le Silbo Gomero | auteur = unescoFrench | site = YouTube | date = 2009-09-29 | consulté le = 2021-01-13 }} * {{lien web | url = https://www.youtube.com/watch?v=tWXVgIkdItY | titre = L'impressionnant langage sifflé en Turquie | auteur = HalalBooking | site = YouTube | date = 2018-02-09 | consulté le = 2021-01-13 }} * {{lien web | url = https://www.youtube.com/watch?v=UtzEjQG0RpI | titre = Le langage du cor des Alpes | auteur = Bruno Spinnler | site = YouTube | date = 2016-02-06 | consulté le = 2021-01-13 }} On peut toutefois se demander si les langues musicales relèvent de la musique ou bien sont simplement l'utilisation d'instruments de musique pour communiquer ; par exemple, si l'on utilise un piano pour faire du morse, est-ce encore de la musique ? Un compositeur ou interprète peut bien sûr intégrer du morse dans son morceau mais alors le sens du message ainsi codé devient secondaire. Par exemple, le ''silbo gomero'' est utilisé comme élément de la chanson ''Silbo'' de Féloche (album ''Silbo'', 2013) mais on ne s'intéresse pas au sens de ce qui est sifflé. * {{lien web | url = https://www.youtube.com/watch?v=jAlnL8JHetY | titre = Féloche - Silbo [Clip officiel] | auteur = FÉLOCHE | site = YouTube | date = 2013-10-15 | consulté le = 2021-01-21 }} {{citation bloc | 1=Si j'émets le son ''aï'', selon que je parle français, anglais ou allemand, cela signifiera une interjection de douleur, le pronom « je », ou un « œuf », tandis qu'en musique c'est le son seul qui agira en tant que tel, avec sa sonorité, sa hauteur, sa durée, son timbre, etc. La musique nous apprend donc à écouter les sons par aux-mêmes, et à les situer dans un ensemble d'autres sons dont la réunion traduit une pensée musicale, qui ne doit rien au langage parlé. […] Contrairement au langage parlé, la musique agit directement sur la sensibilité. | 2 = Jacques Chailley | 3 = ''La Musique'', (éd. Van de Velde, 1975), ISBN 2-85868-024-8, p. 7. }} Il n'y a probablement pas une frontière nette entre langage et musique. Aux deux extrêmes, il y a d'un côté la communication « purement pratique » (transmettre des informations), de l'autre une musique pensée uniquement comme un arrangement abstrait de sons. Mais dès que l'on prend en considération la prosodie de la langue — sonorités (rimes), intonations, rythme, scansion —, en particulier avec la poésie, on fait un pas vers la musique ; et lorsque la musique sert à exprimer un ressenti (sentiment, sensation, humeur) ou appuie une description, elle fait un pas vers le langage. Nous avons ainsi un continuum : communication pratique — poésie — chant — musique expressive — musique abstraite et une même œuvre peut alterner différents modes, par exemple une opérette alterne en général des parties parlées (dialogues ou monologues théâtraux), des récitatifs (parties parlées-chantées), des parties chantées (airs) et des mouvements instrumentaux. Pour clarifier les termes utilisés : pour la musique que nous avons appelé « expressive », nous pouvons citer la ''Lettre à Élise'' (''{{lang|de|Für Elise}}'', Bagatelle n<sup>o</sup>25 en ''la'' mineur WoO 59 de Ludwig van Beethoven, 1810), une lettre d'amour jouée ; ou bien les ''Tableaux d'une exposition'' ({{lang|ru|Картинки с выставки, ''Kartinki s vystavki''}}, Modeste Moussorgski, 1874), chaque pièce évoquant un tableau du peintre Viktor Hartmann, un ami du compositeur mort peu avant. Pour la musique que nous avons qualifiée abstraite, nous pouvons citer les fugues de Bach : chaque fugue présente un thème, une phrase musicale, puis ce thème est repris dans des variations, la composition suivant des règles assez strictes. Bien que composée ''a priori'' sans volonté de communiquer un ressenti ni penser à une personne, une scène ou un objet, l'écoute d'une fugue provoque un ressenti voire une émotion. * {{lien web | url=https://www.dailymotion.com/video/x5gerf0 | titre = Beethoven - Für Elise | Classical Piano Music | auteur = HalidonMusic (Giovanni Umberto Battel) | site = Dailymotion | date = 2017 | consulté le = 2021-01-20 }} * {{lien web | url=https://www.dailymotion.com/video/x6j6pf9 | titre = Moussorgski | Tableaux d’une exposition par Daniel Ciobanu | auteur = France musique | site = Dailymotion | date = 2018 | consulté le = 2021-01-20 }} ** {{lien web | url=https://www.dailymotion.com/video/x7u1x3v | titre = « Tableaux d'une exposition » de Moussorgski / Ravel - Les Clefs de l'orchestre de Jean-François Zygel | auteur = France Inter | site = Dailymotion | date = 2020 | consulté le = 2021-01-20 }} * {{lien web | url=https://www.dailymotion.com/video/x3039ad | titre = Contrapunctus IX from The Art of Fugue | BACH | auteur = Halley Elden (Anderson & Roe Piano Duo) | site = Dailymotion | date = 2016 | consulté le = 2021-01-20 }} Le chant, lorsqu'il comporte des paroles, permet d'associer un message à la musique, de raconter une histoire. La musique est alors en général composée pour soutenir le sens des paroles (mais elle peut aussi introduire un décalage). Prenons par exemple ''La Truite'' de Schubert (''{{lang|de|Die Forelle}}'', lied op. 32, D.550, 1817) : il évoque une truite qui bondit dans un cours d'eau et la musique qui l'accompagne est légère et sautillante. Dans le cas de l'air de ''Kleinzach'' dans l'opérette ''Les Contes d'Hoffmann'' de Jacques Offenbach (1851) : le chanteur incarne le poète Hoffmann qui entonne un chant de taverne où il se moque d'un personnage disgracieux nommé Kleinzach. Dans la chanson ''{{lang|en|Diamonds and Rust}}'' (1975), la chanteuse Joan Baez évoque la mélancolie dans laquelle la plonge un appel téléphonique d'un ancien amant. Lorsque le message est revendicatif et contestataire, la musique peut prendre une certaine violence : par exemple le morceau ''{{lang|en|Anarchy in the UK}}'' des Sex Pistols (album ''{{lang|en|Never Mind the Bollocks}}'', 1976) qui se révolte contre la société anglaise de cette époque, ou bien ''Antisocial'' du groupe Trust (album ''Répression'', 1980). Mais la contestation ne s'accompagne pas forcément d'une musique violente ; les chansons engagées ''({{lang|en|protest song}})'' étatsuniennes des années 1960 se rapprochent de la musique ''folk'', comme par exemple ''Blowin' in the Wind'' de Bob Dylan (1962), les chansons réalistes françaises du début du {{pc|xx}}<sup>e</sup> siècle sont proche du bal musette, comme par exemple ''La Butte rouge'' (Montéhus et Georges Krier, 1923) qui dénonce les horreurs de la Première Guerre mondiale. * {{lien web | url=https://www.dailymotion.com/video/xx40hm | titre = Renée Fleming - Die Forelle - Schubert | auteur = medici.tv | site = Dailymotion | date = 2013 | consulté le = 2021-01-20 }} * {{lien web | url=https://www.dailymotion.com/video/x5sogg | titre = Neil Shicoff Contes d'Hoffmann Kleinzach offenbach | auteur = richardvallouise | site = Dailymotion | date = 2008 | consulté le = 2021-01-20 }} * {{lien web | url=https://www.youtube.com/watch?v=IrVD0bP_ybg | titre = Diamonds And Rust | auteur = Joan Baez | site = YouTube | date = 2018-08-16 | consulté le = 2021-01-20 }} * {{lien web | url=https://www.dailymotion.com/video/x5ia0jv | titre = Sex Pistols - Anarchy in the UK | auteur = Sex Pistols | site = Dailymotion | date = 2017 | consulté le = 2021-01-20 }} * {{lien web | url=https://www.youtube.com/watch?v=JthrO7hn2lQ | titre = Antisocial | auteur = Trust TV | site = YouTube | date = 2014-11-09 | consulté le = 2021-01-21 }} * {{lien web | url=https://www.youtube.com/watch?v=MMFj8uDubsE | titre = Bob Dylan - Blowin' in the Wind (Audio) | auteur = Bob Dylan | site = YouTube | date = 2019-01-25 | consulté le = 2021-01-20 }} * {{lien web | url=https://www.dailymotion.com/video/x1z6uot | titre = Rosalie Dubois - La butte rouge | auteur = Chanson Française | site = Dailymotion | date = 2014 | consulté le = 2021-01-20 }} La voix n'est pas nécessairement utilisée pour former des mots. Il peut s'agir d'imiter des animaux, comme par exemple dans le ''Duo de chats'' de Gioachino Rossini (''{{lang|it|Duetto buffo di due gatti}}'', 1825). La voix peut aussi être utilisée comme n'importe quel instrument : les phonèmes sont utilisés uniquement pour leurs sonorités ; c'est par exemple le cas de ''Nuits'' de Iannis Xenakis (1967). On peut aussi générer des sons avec la bouche autres que la voix, par exemple dans le cas de la ''{{lang|en|beatbox}}''. * {{lien web | url=https://www.youtube.com/watch?v=l7boSJxKAkg | titre = Chinwe Enu & Adrienne Webster - Gioachino ROSSINI - DUETTO BUFFO DI DUE GATTI - (byGigio!!!) | auteur = byGigio | site = YouTube | date = 2014-04-20 | consulté le = 2021-01-21 }} * {{lien web | url=https://www.youtube.com/watch?v=tXjilKOyzow | titre = Iannis Xenakis — Nuits | auteur = MusicaRicercata | site = YouTube | date = 2014-04-20 | consulté le = 2021-01-21 }} * {{lien web | url=https://www.youtube.com/watch?v=CPKq9sDIs2M | titre = Human Beatbox Neil Amazes Everyone | Asia’s Got Talent Episode 4 | auteur = Asia's Got Talent | site = YouTube | date = 2015-04-03 | consulté le = 2021-01-21 }} === Musique et bruit === La musique utilise des sons ; elle peut donc utiliser des bruits. Un bruit et un son jugé indésirable ; il perturbe, déconcentre, empêche d'entendre des sons « utiles » comme ce que dit notre interlocuteur ou bien la musique que l'on voudrait bien entendre. Mais un bruit peut aussi être informatif, il renseigne sur ce qui se passe, avertit d'un danger, il peut même être agréable et recherché (bruit de l'eau, crépitement du feu). Un son n'est donc pas un bruit par nature, il est un bruit parce qu'on le décide. Les musiciens peuvent vouloir évoquer des bruits avec leurs instruments. On peut par exemple citer le ''{{lang|it|Cappriccio stravagante}}'' de Carlo Farina (1627), œuvre dans laquelle il veut évoquer des bruits de la nature (à 13 min. 30 sec. puis à 14 min. 28 sec. dans la vidéo ci-dessous), miaulements, aboiements, pépiements, au sein d'une œuvre plutôt baroque par ailleurs. * {{lien web | url=https://www.youtube.com/watch?v=RzBqnfs7rXY | titre=Farina: Capriccio stravagante for Violin, Two Violas, Cello, and Continuo | auteur= Chamber Music Society of Lincoln Center | site=YouTube | date=2020-04-28 | consulté le=2021-01-28 }} * {{lien web | url=https://www.youtube.com/watch?v=iumvl9CV0kc | titre=INCREDIBLE Choir Makes It Rain During Audition! | auteur=Got Talent Global | site=YouTube | date=2019-03-24 | consulté le=2021-01-27 }} (Coro Divertimento Vocale) Les compositeurs peuvent aussi utiliser des bruits dans leurs œuvres. * {{lien web | url=https://www.youtube.com/watch?v=nW8dGwa2zRw | titre=Typewriter - Brandenburger Symphoniker | auteur= Sabine Schneider | site=YouTube | date=2012-04-22 | consulté le=2021-01-27 }} (''The Typewriter'', Leslie Anderson, 1950) * {{lien web | url=https://www.dailymotion.com/video/x4jyofn | titre=Jacques - Dans la Radio | auteur= Buzzvid | site=Dailymotion | date=2016 | consulté le=2021-01-27 }} (composé à partir de bruits enregistré dans la Maison de la Radio) * {{lien web | url=https://www.youtube.com/watch?v=Kl6bQ6W7f4I | titre=William Tell Overture | auteur= Spike Jones | site=YouTube | date=2015-05-31 | consulté le=2021-01-27 }} * {{lien web | url=https://www.francemusique.fr/culture-musicale/quand-le-bruit-devient-il-musique-88665 | titre=Quand le bruit devient-il musique ? | auteur= Léopold Tobisch | date=2020-10-20 | consulté le=2021-01-27 }} === Musique et autres arts === La musique peut s'associer à d'autres arts. La danse évidemment (voir plus loin) mais aussi le théâtre, la poésie, le cinéma, les arts du cirque, les jeux vidéo et maintenant les jeux de société (en général sous la forme d'une bande son diffusée par un appareil mobile : téléphone portable, tablette tactile). Elle est diffusée dans de nombreux média : radio, télévision, pages internet… Son association avec la poésie est très ancienne ; on peut par exemple citer les aèdes de la Grèce antique qui chantaient les épopées, le plus célèbre étant Homère. De même pour le théâtre, le chœur du théâtre grec antique chante et danse dans l’''orchestra'' (une zone dédiée du théâtre) pour accompagner la pièce. Au cinéma, la musique intervient dès le cinéma muet, un pianiste accompagnant la projection. La musique, partiellement écrite et largement improvisée, permet de renforcer le rythme et l'émotion transmise. Camille Saint-Saëns est le premier à composer une musique pour un film, ''L'Assassinat du duc de Guise'' d’André Calmettes et Charles Le Bargy en 1908. Lorsque le cinéma devient sonore en 1927, la musique s'inscrit naturellement dans la bande son ; le premier film sonorisé est d'ailleurs un film musical, ''Le Chanteur de jazz ({{lang|en|The Jazz Singer}})'' d'Alan Crosland. Comme il n'y a plus de problème de synchronisation, puisque la musique est très vite inscrite sur la pellicule, cela permet de faire intervenir plus d'instruments, des orchestres symphoniques complets. Au cinéma, la musique peut aller du simple rôle de « papier peint » comme le disait Igor Stravinsky, c'est-à-dire qu'elle se déroule en arrière-plan sans attirer l'attention sur elle pour laisser la priorité à l'image et à la parole, jusqu'à être intimement lié à l'image comme un opéra. {{citation bloc |Et — si je peux me permettre de poser moi-même une question — pourquoi prendre au sérieux la musique de film ? Les gens du cinéma admettent eux-mêmes pour être la plus satisfaisante, elle ne devrait pas être perçue en tant que telle. Je suis d'accord avec cela. Je crois qu'elle ne devrait gêner ou heurter l'action, et qu'elle devrait remplir son rôle de papier-peint en ayant la même relation à l'œuvre dramatique que la musique de restaurant a avec la conversation qui se déroule à la table individuelle. |2=Igor Stravinsky |3={{article |prénom1=Ingolf |nom1=Dahl |titre=Igor Stravinsly on Film Music |périodique=The Musical Digest |date=1946-09 |url=http://www.filmmusicsociety.org/news_events/features/2003/101003.html |consulté le=2026-02-10}} }} La musique de film peut être « intra-diégétique », c'est-à-dire qu'elle fait partie de la fiction, qu'elle est entendue par les personnages, par exemple s'ils assistent à un concert ou si elle est diffusée par des haut-parleurs (radio, télé, chaîne hifi, enceinte connectée, musique d'ambiance d'un lieu public) dans l'histoire. Elle peut aussi être « extra-diégétique » c'est-à-dire hors fiction, elle accompagne les images, mais n'est pas entendue par les personnages. Il y a parfois un passage intra-diégétique/extra-diégétique, par exemple la musique est diffusée à la radio (avec le son légèrement nasillard caractéristique) puis devient la musique de fond du film (avec une qualité sonore alors irréprochable), ou vice-versa. L'opposition intra/extra-diégétique n'est pas toujours pertinente. Par exemple, dans le cas d'un opéra, une comédie musicale, un film musical… les personnages s'expriment en chantant. Il ne s'agit pas d'un monde imaginaire dans lequel les gens chanteraient et où de la musique se ferait entendre dans l'air… mais pourtant les personnages entendent bien la musique, puisqu'ils chantent en rythme et juste avec cet accompagnement, et ils entendent ce que chantent les autres. C'est en fait comme dans les pièces de théâtre écrites en vers : les personnages ne s'expriment pas de manière « naturelle » mais avec des contraintes esthétiques. La musique peut renforcer des sentiments éprouvés par les personnages pour essayer de les faire ressentir aux spectateurs et spectatrices (joie, tristesse, peur…), elle peut accompagner le rythme de l'action (rapide, lent). Elle peut évoquer une époque, un lieu et un milieu social, par exemple musique baroque à l'époque baroque, rock 'n roll dans les années 1950 aux États-Unis… Elle peut contenir des thèmes liés aux personnages ou à des éléments de l'histoire, à l'image des ''{{lang|de|leitmotiv}}'' utilisés par Richard Wagner et Franz Liszt (et popularisé par John Williams dans la saga de ''La Guerre des étoiles, Star Wars''). La musique est également un marqueur de l'époque à laquelle a été faite le film. Par exemple, dans les années 1950-1960, les films de science-fiction utilisaient souvent de la musique électronique (thérémine, ondes Marteneau) car cela « faisait moderne » et permettait de se projeter dans l'avenir ; on pourra écouter la bande son du ''Jour où la Terre s'arrêta'' (Robert Wise, 1951, musique de Bernard Herrmann), ''La Guerre des mondes'' (Byron Haskin, 1953, musique de Leith Stevens) ou ''Planète interdite'' (Fred McLeod Wilcox, 1956, musique de Louis et Bebe Barron). Mais dans ''2001, l'Odyssée de l'espace'' (1969), Stanley Kubrick décide d'utiliser de la musique classique (Richard Strauss, Johan Strauss fils, Aram Khatchatourian) et contemporaine (György Ligeti, Alex North). La musique peut aussi accompagner des expositions d'art plastique ou des performances. Dans les médias, la musique peut servir : * de ponctuation, pour annoncer le début ou la fin d'une émission, d'une chronique, par un générique ou un sonal ''({{lang|en|jingle}})'' ; * de respiration en radio : elle fournit une pause dans une émission ou un reportage, ce qui permet à l'auditeur ou à l'auditrice de relâcher son attention ou de faire le point, d'assimiler les informations énoncées précédemment ; * de véhiculer une émotion, d'attirer l'attention, pour supporter une fiction (comme avec la poésie, le théâtre ou le cinéma) ou pour promouvoir une idée, un produit (publicité, propagande) ; * dans les lieux recevant du public, la musique peut servir à créer une ambiance particulière et les sonals ''({{lang|en|jingles}})'' servent à attirer l'attention avant une annonce ; * les médias sont aussi une manière de diffuser la musique et de promouvoir les artistes. '''Étude de cas : quelques exemples d'utilisation de musique au cinéma et dans les séries télévisées''' 1. ''M le maudit'', Fritz Lang (1931) C'est le premier film parlant de Fritz Lang. Dans ce film policier, le criminel est reconnu par un mendiant aveugle car il siffle ''Dans l'antre du roi de la montagne'' (thème de ''Peer Gynt'' d'Edvard Grieg, 1874). C'est un exemple de musique intra-diégétique et la musique est au cœur de l'intrigue. 2. ''Il était une fois dans l'Ouest'', Sergio Leone (1968) La musique de ce western « spaghetti » (réalisé par un italien et pas par un étatsunien) est signée Ennio Morricone, comme tous les autres films du réalisateur. Un des personnages (incarné par Charles Bronson) est un joueur d'harmonica, d'ailleurs surnommé « Harmonica ». Ce personnage est attaché à un thème, ''L'Homme à l'harmonica'', et fait référence au passé du personnage et en particulier à sa respiration et à la fatigue. 3. ''2001, l'Odyssée de l'espace'' Le film utilise six compositeurs aux points clef. Le premier morceau est le début d’''Ainsi parlait Zarathoustra'' de Richard Strauss (1896). Ce thème introduit la côté de divin et prophétique des mystérieux monolithes, des rectangles de pierre noire qui subliment l'humain. Le deuxième morceau est ''Le beau Danube bleu'' de Johann Strauss fils (1866) ; ici, ce n'est pas la musique qui se plie à l'action, c'est l'action qui suit la musique, la pièce étant diffusée en entier. La valse évoque le voyage spatial et la rotation des stations spatiales qui permet d'assurer une gravité artificielle, dessinant une danse avec la navette de transport. Le troisième compositeur est György Ligeti, avec un extrait du ''Requiem'' (1965) et un autre de ''Lux Æterna'' (1966). Il s'agit là encore de morceaux à connotation religieuse, et c'est de la musique atonale. Le spectateur n'a ainsi pas les références musicales habituelles, les morceaux ont un côté désagréable, et introduit ici une forme de peur et de vertige face à l'inconnu. Le quatrième est Aram Khatchatourian avec l’''Adagio'' de ''Gayaneh'' (1939). Son caractère désolé et apaisé évoque l'isolement du vaisseau spatial ''Discovery'' dans l'espace. Kubrick a commandé une partition originale à Alex North pour l'introduction du film et la césure au milieu. Ces morceaux de deux minutes sont diffusés avec un écran noir et servent à mettre le spectateur ou la spectatrice dans l'ambiance. Enfin, l'ordinateur HAL 9000 (CARL 500 en français) chante ''Daisy Bell'' de Harry Dacre, une chanson pour enfant qu'a apprise l'ordinateur lors de son apprentissage. C'est la première chanson qui a été interprétée par un ordinateur (un IBM 7094) en 1961 ; la musique est donc un clin d’œil caché (ce que l'on appelle maintenant un « œuf de Pâque », ''Easteregg''). 4. ''Cold case : Affaires classées/Victimes du passé'', Meredith Stiehm, 2003-2010 Cette série télévisée parle d'affaires classées, de crimes anciens non élucidés. Chaque épisode utilise des morceaux de musique populaires à l'époque des meurtres. == Musiques savante, populaire et traditionnelle == On distingue parfois la musique savante, la musique populaire et la musique traditionnelle. La musique savante est une musique suivant des règles strictes ; elle nécessite d'être étudiée pour pouvoir être jouée et est conservée sous forme écrite (partition). En occident, elle est synonyme de « musique classique ». La musique populaire, ou « musique pop », est une musique jouée par des personnes issues du milieu populaire, à destination de personnes du milieu populaire. La musique traditionnelle est une musique liée à une zone géographique ; elle est souvent de tradition orale, transmise par enseignement direct. Ces distinctions ont un caractère artificiel. Ainsi, le jazz peut être considéré comme une musique populaire puisqu'elle est née dans les couches populaires des États-Unis ; pourtant, il peut suivre des règles strictes, est enseigné dans des écoles, par exemple les conservatoires en France, et est en partie conservé par écrit (thèmes et grilles d'accords). Au {{pc|xxi}}{{e}} siècle, certains qualifieront certainement le jazz de musique élitiste, la musique la plus écoutée en France étant le rap. La musique traditionnelle est à l'origine aussi une musique populaire. La musique rock, une musique populaire par essence, peut parfois avoir une complexité qui la rapproche de la musique savante en particulier dans le rock progressif et le heavy metal. Le terme de « musique classique » est lui aussi ambigu. Il est créé au {{pc|xviii}}{{e}} siècle pour désigner la musique de compositeurs et compositrices comme Haydn, Mozart et Beethoven, de 1750 à 1830. Elle a ensuite été reprise par l'industrie musicale naissante, et désigne maintenant 800 ans de musique, du Moyen-Âge à la musique contemporaine<ref>{{lien web |url=https://www.radioclassique.fr/histoire/mouvements/musique-classique-pourquoi-sappelle-t-elle-comme-ca/ |titre=“Musique classique” : Pourquoi s’appelle-t-elle comme ça |auteur=Clément Kasser |site=Radio Classique |date=2023-01-12 |consulté le=2025-07-08}}.</ref> ; lorsque l'on veut parler de la période « Haydn, Mozart et Beethoven, de 1750 à 1830 », on parle maintenant de « classicisme » ou de « période classique » (qui suit la période baroque et précède le romantisme). ;Exemples de musiques populaires * {{lien web | url = https://www.youtube.com/watch?v=5yq1HaKtOmU | titre = Les esclaves et le blues (Arte) | auteur = CouvreFeu911 | site = YouTube | date = 2009-05-20 | consulté le = 2021-01-14 }} * {{lien web | url = https://view.genial.ly/5ff31cc5485b460cf9c7acbb/interactive-image-selection-ress-rap-cdi | titre = À la (re)découverte du rap | auteur=sabrina.ducroquet | site = Genially | date=2021-01-04 | consulté le = 2021-01-15 }} * {{lien web | url = https://www.radiofrance.fr/francemusique/podcasts/maxxi-classique/la-signature-musicale-d-iron-maiden-8841587 | titre = La signature musicale d’Iron Maiden | auteur= Max Dozolme | site = France Musique/MAXXI Classique | date=2026-08-20 | consulté le = 2026-09-01 }} == Les instruments de musique == On classe en général les instruments de musique par la manière dont ils produisent les sons. Pour découvrir les instruments, l'idéal est d'assister à des concerts ou des présentations par des musiciens et musiciennes. On peut également utiliser des contes musicaux comme ''Pierre et le Loup'' ({{lang|eu|Петя и волк, ''Pétya i volk''}}, Sergueï Prokofiev, 1936) ou ''Piccolo, Saxo et compagnie'' (Jean Broussolle et André Popp, 1956), ou encore diffuser ''Les Zinstrus'', émission de Saskia de Ville sur France Musique. : {{lien web | url=https://www.francemusique.fr/emissions/les-zinstrus/decouvrez-les-zinstrus-le-nouveau-podcast-de-france-musique-en-exclusivite-sur-l-application-radio-france-96298 | titre=Les Zinstrus | auteur=Saskia de Ville | site=France Musique | date=2021-06-02 | consulté le=2021-06-07 }} === Les instruments naturels === Les sons sont produits par le corps humain seul : * voix, chant ; * percussions corporelles ; * sifflements. === Les instruments à corde === Les sons sont produits par une corde qui vibre. [[Fichier:Cello bow.jpg|vignette|Corde frottée par un archet (violoncelle).]] ;Instruments à corde frottée : Les cordes sont frottées par un archet ou par une roue {{colonnes|nombre=2|1= * violon ; * violon alto, ou alto ; * violoncelle ; * contrebasse ; * viole de gambe ; * vielle à roue ; * psaltérion ; * arc musical ; * erhu (violon chinois à deux cordes) ; * sokou (violon africain) ; * endingidi (violon africain) ; * orutu (violon africain). }} <gallery> Violin VL100.png | violon Langot2.jpg | violoncelle Andreas Unge Cosmopolite Djangofestivalen (214050).jpg | contrebasse DraailierWiel.jpg | Roue frottant sur les cordes d'une vielle </gallery> ; Instruments à cordes pincées : Les cordes sont pincées avec les doigts, un plectre (médiator) ou un dispositif mécanique (sautereau du clavecin) {{colonnes|nombre=2|1= * harpe ; * guitare ; * violon, alto, violoncelle, contrebasse joués en pizzicati ; * clavecin ; * luth ; * théorbe ; * lyre ; * épinette ; * psaltérion ; * cithare ; * oud ; * saz, baglama, tambûr ; * sanxian, shamisen ; * pipa, biwa ; * monocorde (cithare vietnamienne, ''{{lang|vi|đàn bầu}}'') ; * kora ; * koto ; * nyatiti ; * simbing. }} <gallery> Harpe celtique moderne (Camac).jpg | harpe celtique Konzertharfe.jpg | harpe à pédales Classical Guitar two views.jpg | Guitare classique 8-course-tenor-renaissance-lute-04.jpg | Luth Nyatiti.jpg | Nyatiti Giovanni Battista Dall'Olio, Salterio, Museo Civico di Modena.tif | Psaltérion Djélymady Cissoko 2.jpg | Kora Vietnamese musical instrument Dan bau 2.jpg | Monocorde ''({{lang|vi|đàn bầu}})'' Simbing 2011.JPG | Simbing 1978 Fender Stratocaster hardtail & Marshall Amp (2008-11-02 16.03.17 by irish10567).jpg | Guitare électrique et amplificateur Biwa-hoshi.jpg | Biwa Fumie Hihara, au shamisen (danse du Kabuki, musée Guimet).jpg | Shamisen Woman Playing Koto, albumen silver print, Honolulu Museum of Art 2008-27-79.JPG | Koto </gallery> ; Instruments à cordes frappées : Les cordes sont frappées avec une mailloche ou un système mécanique à marteau * piano ; * tympanon ; * berimbau, m'bulumbumba (arc musical afro-brésilien). <gallery> Steinway & Sons concert grand piano, model D-274, manufactured at Steinway's factory in Hamburg, Germany.png | piano à queue Pianodroit.jpg | piano droit Hammered dulcimer reenactor.JPG | Tympanon History of Inventions USNM 41 Angola Muscial Bow.jpg | Berimbau COLLECTIE TROPENMUSEUM Een Samo muzikant bespeelt de muziekboog TMnr 20010316.jpg | Arc musical </gallery> === Les instruments à vent === [[Fichier:Musical instruments of prehistory.jpg|vignette|Instruments préhistoriques]] Les sons sont produits en perturbant un flux d'air. ==== Les bois ==== Le son est produit par un « sifflet ». Contrairement à son nom, un bois n'est pas nécessairement en bois : les flûtes traversières modernes et les saxophones sont en métal, les flûtes et sifflets préhistoriques pouvaient être en os. [[Fichier:Montre patine bronze.jpg|vignette|Bouche des tuyaux d'orgue (sifflet).]] ; Instruments à biseau : L'air est dévié par un biseau ou par la position de la bouche par rapport à l'ouverture de l'instrument * flûte à bec, pipeau, ''{{lang|en|tin whistle}}'' ; * flûte traversière et piccolo ; * flûte oblique ; * flûte de Pan ; * tuyaux d'orgue à bouche ; * sifflet ; * rhombe (plaque qui siffle lorsqu'on la fait tournoyer). {{clear}} <gallery> Turndun Bora Bora from New Zealand.jpg | rhombe Soprano-recorder.jpg | flûte à bec Kathrin Lemke Unterfahrt 2012-03-23-001s.jpg | flûte traversière Gasba de Beni Salah.jpg | flûte oblique Latinoamerican Pan flute.jpg | flûte de Pan SambaWhistle.jpg | sifflet de samba </gallery> [[Fichier:Bassoon Reeds.jpg|vignette|Anche double pour basson.]] ; Instruments à anche : Le sifflet est composé d'une ou deux lames de roseau (anche simple, anche double), parfois de métal * hautbois, cor anglais ; * clarinette ; * saxophone ; * bombarde ; * biniou, cornemuse, uilleann pipes ; * harmonica ; * accordéon, bandonéon ; * jeu d'anche (tuyaux d'orgue à anche). {{clear}} <gallery> Anche de clairon (orgue).jpg | anche de tuyau d'orgue (clairon) Oboe-player.jpg | hautbois Romeo Boninsegna (IMSO pp25).jpg | cor anglais Bassoon (26039503553).jpg | basson|alt=basson Blåser så klarinetten krackelerar! (2996867460).jpg | clarinette Joueur de saxophone.jpg | saxophone ténor Sopraansaxofoon Gentse Feesten 20-07-2019.jpg | saxophone soprano Anna Kristina playing the Harmonica 2019 by Glenn Francis.jpg | Harmonica Paris - Accordion Player - 0956.jpg | accordéon Bandoneon diatoniczny.jpg | bandonéon Bagpiper at Spanish Bay, CA.jpg | cornemuse Festival de Cornouaille 2015 - Championnat des bagadoù - Kerlenn Pondi 02.JPG | biniou Sonneur de bombarde, Festival Les Orientales (Saint-Florent-Le-Vieil).jpg | bombarde </gallery> ==== Les cuivres ==== Les sons sont créés par la vibration des lèvres. Le nom provient du fait qu'ils sont souvent faits en laiton, un alliage de cuivre, mais ce n'est pas systématique. {{colonnes|nombre=2|1= * Trompette ; * cornet à pistons ; * clairon ; * trombone à coulisse ; * trombone à piston ; * tuba ; * saxhorn, bugle ; * hélicon ; * soubassophone ; * cor d'harmonie ; * cor de chasse ; * cornet à bouquin ; * cor des Alpes ; * didjeridoo (ou didgeridoo). }} <gallery> Maite Hontelé.jpg | trompette 80-G-40995 (31399109310).jpg | clairon Dodenherdenking Montfort 2013 33.JPG | bugle Zoetermeer Caribbean Festival trombonists.jpg | trombone à coulisse LyceumSimplyThree-0231.jpg | cor d'harmonie 03.11.1963.Saint Hubert. (1963) - 53Fi3146.jpg | cor de chasse Havana - Cuba - 3065.jpg | tuba Military orchestra in front of the Stockholm Palace 08 (cropped).jpg | hélicon Hot 8 Bennie Pete Old Algiers Riverfest.jpg | soubassophone Australia Aboriginal Culture 009.jpg | didjeridoo Alphorn player in Wallis.jpg | cor des Alpes Three cornetts.jpg | cornets à bouquin </gallery> ; Vidéo * {{lien web | url = https://www.youtube.com/watch?v=cgfcR8ctW7 | titre = L'acoustique des tuyaux PVC | auteur = Nicolas Bras - Musiques de Nulle Part | site = YouTube | date = 2013-07-29 | consulté le = 2012-01-15 }} === Les percussions === Les sons sont produits en frappant un objet avec la main, le pied ou un maillet : {{colonnes|nombre=2|1= * tambour ; * timbale ; * triangle ; * vibraphone, xylophone, métallophone, glockenspiel, balafon ; * carillon ; * cloches ; * caisse claire ; * charleston ; * grosse caisse ; * cymbale ; * batterie : association de plusieurs percussions, en général une caisse claire, un charleston, une grosse caisse à pédale, des toms, des cymbales (dont un ''ride'' et un ''crash'') ; * claves ; * percussions corporelles ; * darbouka ; * djembé. }} <gallery> Tambour v. Dragoner R. Schönborn 1716 (NYPL b14896507-90039).tiff | tambour militaire Kettledrum (PSF).png | timbales Triangle (13513809145).jpg | triangle Fastnachtsumzug in Arbon. 2008-02-10 15-24-58.jpg | grosse caisse Bala player 2.jpg | balafon (xylophone) et djembé CarillonAlteNikolaikircheFrankfurt2.jpg | carillon Cymbals (IMSO pp55).jpg | cymbales Music Instruments - ബാന്റ് സെറ്റ് ഉപകരണങ്ങൾ 12.JPG | caisse claire Charly Antolini.jpg | batterie Playingclaves.jpg | claves </gallery> === Les instruments à lamelles ressort === Le son est produit par une lamelle que l'on fléchit avant de la lâcher : * guimbarde ; * piano à pouce : sanza, mbira. Parfois, la lame ressort est mise en vibration par un archet : * vibraphone, que l'on peut jouer avec un archet ; * scie musicale. <gallery> Maultrom 1a.jpg | Guimbardes. Kolomenskoe2015TAE 01 Player.jpg | Joueur de guimbarde. Holding an mbira dzavadzimu.jpg | Piano à pouces (sanza, mbira). </gallery> === classification Hornbostel-Sachs === La classification « classique » est héritée de l'Histoire de la musique savante occidentale et donnes quelques étrangetés, comme des bois qui sont en métal (flûte traversière, saxophones) ou des cuivres qui sont en bois (didjeridoo). En 1914, Erich von Hornbostel et Curt Sachs ont créé un système de classification plus systématique. ; Idiophone : instrument dont le son est produit par la vibration de l'instrument suite à une percussion ; * idiophone frappé : l'instrument est mis en vibration en le frappant ; ** idiophone frappé directement : l'instrumentiste fait un mouvement de frappe, que ce soit direct ou par un mécanisme intermédiaire (pédale, touche de clavier…) : *** instrument à concussion : deux ou plusieurs parties sont entrechoquées : bâtons (claves), plaques (fouet), gouttières (cuillers, castagnettes, cymbales) ; *** idiophones à percussion : la percussion se fait avec un objet qui lui-même ne sonne pas (main, baguette…) : bâtons à percussion (triangle, xylophone, marimba), plaque (métallophone, glockenspiel, célesta, vibraphone), tube (cloche tubulaire, tambour à lèvre, carillon), récipient (cymbale, gong, wood-block, steel drum, cloche) ** idiophone frappé indirectement : l'instrumentiste fait un mouvement mais ce n'est pas une frappe : *** idiophone secoué : maracas, bâton de pluie, tambourin ; *** idiophone raclé : crécelle, washboard ; *** idiophone fendu : une baguette est fendue et les lamelles s'entrechoquent ; * idiophone pincé : en forme de cadre (guimbarde), en forme de peigne (kalimba, sanza, boîte à musique) ; * idiophone frotté * idiophone soufflé * autres idiophones ; Membranophones ; Cordophones ; Aérophones ; Électrophones {{...}} == Danse == * procédés chorégraphiques : https://www.youtube.com/watch?v=S6W6FhBim0U * https://www.youtube.com/watch?v=wgblAOzedFc * https://www.youtube.com/watch?v=dC1BqNEbz5Y == Voir aussi == * {{lien web |url=https://www.youtube.com/watch?v=6M5q9DG4DaQ |titre=Comment les instruments s’accordent dans un orchestre ? |site=C'est pas sorcier (YouTube) |date=2026-09-21 |consulté le=2022-03-02 }} * {{lien web |url=https://www.youtube.com/watch?v=cTYvCpLRwao |titre=Les mathématiques de la musique (avec Vled Tapas) |site=Science étonnante (YouTube) |date=2018 |consulté le=2022-03-02 }} * {{lien web |url=https://www.radiofrance.fr/franceinter/podcasts/sur-les-epaules-de-darwin/a-la-recherche-des-traces-des-musiques-de-la-prehistoire-8485583 |titre=À la recherche des traces des musiques de la préhistoire |auteur=Jean-Claude Ameisen |site=France Inter (Sur les épaules de Darwin) |date=2019-08-03 |consulté le=2025-04-11 }} * {{lien web |url=https://www.radiofrance.fr/franceinter/podcasts/sur-les-epaules-de-darwin/des-musiques-venues-du-fond-des-ages-1908530 |titre=Des musiques venues du fond des âges |auteur=Jean-Claude Ameisen |site=France Inter (Sur les épaules de Darwin) |date=2016-10-10 |consulté le=2025-04-11 }} * {{lien web |url=https://www.radiofrance.fr/franceculture/podcasts/serie-les-pouvoirs-de-la-musique |titre=Les Pouvoirs de la musique |auteur=Barbara Tillmann |site=France Culture |date=2025-04-23 |consulté le=2025-04-28 }} * {{lien web |url=https://www.youtube.com/watch?v=59Gozx4odSM |titre=Les ondes Martenot, comment ça marche ? - Culture Prime |auteur=Thomas Bloch |site=YouTube, chaîne France Musique |date=2025-06-14 |consulté le=2025-06-16 }} ---- ''[[../|Introduction]]'' &lt; [[../|↑]] &gt; ''[[../Transmettre la musique|Transmettre la musique]]'' [[Catégorie:Formation musicale (livre)|Questce que la musique]] rf20n1eofs84q0vbb662v1zuu814gp9 Mathc complexes/a277 0 81080 774128 774107 2026-10-04T13:32:13Z Xhungab 23827 774128 wikitext text/x-wiki __NOTOC__ [[Catégorie:Mathc complexes (livre)]] [[Mathc complexes/Sommaire#Utilitaires Matrices| Sommaire]] '''L'étude de ce chapitre peut ce faire à l'aide de cette [[https://youtube.com/playlist?list=PLi6peGpf8EPNHUnib_ehtvFhNxJMWqn5Y&si=KIbSRJw_xjHl815R Playlist]].''' {{Partie{{{type|}}}| Matrices pour la transformée de Fourier discrète (TFD):}} 1) L'idée générale Nous allons utiliser la transformée de Fourier discrète pour transformer une suite de valeurs d'un signal définie dans le temps : s[0], s[1], s[2], … , s[N − 1] En une suite qui décrira les fréquences présentes dans ce signal. S(0), S(1), S(2), … , S(N − 1) Nous pouvons concevoir la TFD comme un dispositif pour repérer les fréquences. 2. '''la transformée de Fourier discrète''' (TFD) : _ N-1 n : indice temporel \ k : indice fréquentiel S(k) = | s(n) exp('''-i''' 2PI/N k n) pour 0 <= k < N N : nombre d'échantillons /_n=0 3. '''la transformée de Fourier discrète Inverse''' (TFDI) : _ N-1 n : indice temporel \ k : indice fréquentiel s[n] = '''1/N''' | S(k) exp('''+i''' 2PI/N k n) pour 0 <= n < N N : nombre d'échantillons /_k=0 4) Remarque : La formule d'Euler: exp(iθ) = cos(θ) + i sin(θ) Les fréquences présentes dans x[n] sont principalement recherchées par la TFD en utilisant des sinus et des cosinus. Cela explique l'apparition de '''exp(−i θ) = cos(θ) − i sin(θ)''' Rappel : cos(θ) = (exp(iθ) + exp(-iθ))/2 sin(θ) = (exp(iθ) - exp(-iθ))/2 i Notre étude  nous a permis de comprendre que l'on pouvait utiliser le calcul matriciel pour effectuer ce travail. * Dans un premier temps nous construirons manuellement cette matrice. * Dans un deuxième temps, nous demanderons au langage C de construire cette matrice. Nous verrons qu'en prenant '''le conjugué''' de cette matrice, ''''multiplié par l'inverse de N''', (le nombre d'échantillons), nous allons obtenir '''la transformée de Fourier discrète inverse'''. ==Copier ces fichier avec la bibliothèque sur les matrices complexes :== *[[Mathc complexes/a278| dft.h]] *[[Mathc complexes/a279| f.h]] ==Les fichiers principaux :== *[[Mathc complexes/a280| c03a.c]] Une matrice 3x3 *[[Mathc complexes/a281| c04a.c]] Une matrice 4x4 *[[Mathc complexes/09y| c04b.c]] Une matrice 4x4 ... [https://fr.wikipedia.org/wiki/Transformation_de_Fourier_discr%C3%A8te#Exemple_d'application_à_un_signal Wikipédia] ... [[Mathc complexes/0a2| La TFD sans les matrices]] ... [[Mathc complexes/0a9| La TFDI sans les matrices]] ... [[Mathc complexes/0a4| exp('''(-i)''' 3/2 PI)]] *[[Mathc complexes/0a0| c04c.c]] Une matrice 4x4 ... [https://en.wikipedia.org/wiki/Discrete_Fourier_transform#Example Wikipédia] ... [[Mathc complexes/0a3| La TFD sans les matrices]] ... [[Mathc complexes/0aa| La TFDI sans les matrices]] *[[Mathc complexes/a282| c0na.c]] Une matrice nxn *[[Mathc complexes/09z| c0nb.c]] Une matrice nxn ... [https://fr.wikipedia.org/wiki/Transformation_de_Fourier_discr%C3%A8te#Exemple_d'application_à_un_signal Wikipédia] ... [[Mathc complexes/0a2| La TFD sans les matrices]] ... [[Mathc complexes/0a9| La TFDI sans les matrices]] ... [[Mathc complexes/0ab| exp('''(+i)''' 3/2 PI)]] *[[Mathc complexes/0a1| c0nc.c]] Une matrice nxn ... [https://en.wikipedia.org/wiki/Discrete_Fourier_transform#Example Wikipédia] ... [[Mathc complexes/0a3| La TFD sans les matrices]] ... [[Mathc complexes/0aa| La TFDI sans les matrices]] ==Propriétés :== *[[Mathc complexes/a283| c0bna.c]] La formule de Bessel (Une matrice nxn) ==Complément :== Calculer '''la mesure principale de''' : *[[Mathc complexes/0a5| (-67 PI)/5]] *[[Mathc complexes/0a6| (+67 PI)/5]] *[[Mathc complexes/0a7| (+2 PI)]] *[[Mathc complexes/0a8| (-PI)]] S'entraîner sur '''les nombres complexes''' : *[[Mathc complexes/0ac|(+3-i)(i)]] {{AutoCat}} iwh6vgvn8bps4bqrt93kw29d26v3orm Wikilivres:Le Bistro/2026 4 83439 774180 772363 2026-10-04T22:06:57Z Antimuonium 57885 /* Des RAW tout frais, à lire sans délai ! */ nouvelle section 774180 wikitext text/x-wiki <noinclude>{{Wikilivres:Le Bistro/En-tête}}</noinclude> == Le meilleur à Wikilivres pour 2026 ! == Je crée la page du bistrot 2026 en transmettant mes vœux. Bonne année éditoriale à tous ! [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 16 janvier 2026 à 06:05 (CET) :Merci, meilleurs vœux ! [[Utilisateur:JackPotte|JackPotte]] ([[Discussion utilisateur:JackPotte|<span style="color:#FF6600">$</span>♠]]) 16 janvier 2026 à 09:53 (CET) ::Meilleurs vœux pour 2026 ! ::--&nbsp;◄&nbsp;[[Utilisateur:DavidL|'''D'''avid&nbsp;'''L''']]&nbsp;•&nbsp;[[Discussion Utilisateur:DavidL|discuter]]&nbsp;► 16 janvier 2026 à 20:13 (CET) :::Meilleurs vœux pour 2026 :::[[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 17 janvier 2026 à 11:56 (CET) ::::Bonne année et bonnes lectures et écritures ! ::::[[Utilisateur:Matthius|Matthius]] == Page orpheline du livre : États généraux du multilinguisme dans les outre-mer == Bonjour, Le livre "États généraux du multilinguisme dans les outre-mer" a laissé quelques pages orphelines. Ces pages orphelines ont été recopiées ou regroupé dans le texte du livre. Elles font donc doublons. Je montre dans ce livre [[Wikilivres:Feuilles dupliquées orphelines]] cette suite de pages orphelines accompagnée par la page où elles ont été recopiées. Merci [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 10 février 2026 à 19:57 (CET) :@[[Utilisateur:Xhungab|Xhungab]], @[[Utilisateur:JackPotte|JackPotte]], @[[Utilisateur:DavidL|DavidL]], bonjour. Je vois que l'on est en campagne de gestion de pages orphelines et je me pose la question de savoir si on doit les supprimer une fois réintégrées ailleurs ou les supprimer ? Je vois que les deux cas de figure ont été mis en œuvre précédemment. L'avantage de la fusion est de conserver l'historique des modifications, mais je ne suis pas habitué à le faire. Si quelqu'un d'entre vous pouvait m'indiquer une page d'explication, cela m'aiderait à m'y mettre. Ensuite, je me demande si la fusion a vraiment un sens lorsque c'est la même personne qui a créé la page orpheline et la nouvelle au contenu copié collé pour insertion ? Qu'en pensez-vous ? [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 13 février 2026 à 16:02 (CET) :::Ça fait plaisir de voir une personne s'investir dans la maintenance de Wikilivres =). Soit le ou la bienvenu.e. J'attends les avis des autres administrateurs pour te donner mon soutien @[[Utilisateur:Xhungab|Xhungab]]. @ bientôt. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 13 février 2026 à 16:27 (CET) :::: @[[Utilisateur:Lionel Scheepmans|Lionel Scheepmans]] bonjour, je n'ai pas compris en quoi "deux cas de figure ont été mis en œuvre précédemment" : si la page est vide on la supprime et si elle a au moins une phrase valable on la fusionne. [[Utilisateur:JackPotte|JackPotte]] ([[Discussion utilisateur:JackPotte|<span style="color:#FF6600">$</span>♠]]) 13 février 2026 à 16:26 (CET) ::::Concernant l'auteur pour moi cela importe peu, car on souhaite conserver chaque élément permettant de prouver une primauté du droit d'auteur (par exemple pour ne pas accuser un contributeur d'avoir copié un site miroir de sa page supprimée). [[Utilisateur:JackPotte|JackPotte]] ([[Discussion utilisateur:JackPotte|<span style="color:#FF6600">$</span>♠]]) 13 février 2026 à 16:30 (CET) :::::Désolé [[Utilisateur:JackPotte|JackPotte]], je n'avais pas vu que les pages que tu as supprimées étaient vides. La règle est donc supprimer si vide et fusionner si non. Tu peux me conseiller une page d'aide pour oppérer les fusions ? [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 13 février 2026 à 16:34 (CET) ::::::Salut, ::::::Pour la fusion d'historique : ::::::* soit [[Wikilivres:Le guide de l'administrateur#Fusionner deux pages|Utiliser la page spéciale pour cela]] (cependant ne fonctionne pas toujours), ::::::* soit [https://fr.wikibooks.org/w/index.php?title=Wikilivres:Le_guide_de_l%27administrateur&oldid=588957#Fusionner_deux_pages utiliser l'ancienne méthode] qui reste toujours valable quand la première ne fonctionne pas. ::::::--&nbsp;◄&nbsp;[[Utilisateur:DavidL|'''D'''avid&nbsp;'''L''']]&nbsp;•&nbsp;[[Discussion Utilisateur:DavidL|discuter]]&nbsp;► 13 février 2026 à 19:34 (CET) :::::::Salut @[[Utilisateur:DavidL|DavidL]]. Je viens de tester l'outil fusionner avec les deux pages suivantes :::::::[[États généraux du multilinguisme dans les outre-mer/Annexes/Contexte]] :::::::vers :::::::[[États généraux du multilinguisme dans les outre-mer/Annexes]] :::::::Et j'ai le message suivant : :::::::« La période spécifiée chevauche les versions préexistantes de la page de destination. » :::::::Tu peux m'expliquer si tu as le temps ? [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 16 février 2026 à 00:19 (CET) ::::::::Salut @[[Utilisateur:Lionel Scheepmans|Lionel Scheepmans]] ::::::::Dans ce cas, j'utilise [https://fr.wikibooks.org/w/index.php?title=Wikilivres:Le_guide_de_l%27administrateur&oldid=588957#Fusionner_deux_pages l'ancienne méthode]. ::::::::# Renommer A (page qui n'existera plus) vers B, sans laisser de redirection, et en cochant la case de suppression de B (case qui apparait après 1er clic sur le bouton Renommer) (&rarr; l'historique de A est sur B, celui de B est supprimé) ::::::::# Supprimer B (&rarr; l'historique de A et B sont supprimés) ::::::::# Restaurer B et cocher toutes les cases des versions (bouton "Inverser la sélection") (&rarr; restaurer l'historique de A et B) ::::::::# Effectuer une modification de la version finale de la page à afficher, trouvée dans l'historique. ::::::::--&nbsp;◄&nbsp;[[Utilisateur:DavidL|'''D'''avid&nbsp;'''L''']]&nbsp;•&nbsp;[[Discussion Utilisateur:DavidL|discuter]]&nbsp;► 16 février 2026 à 08:29 (CET) :::::::::Merci @[[Utilisateur:DavidL|DavidL]]. Ça m'a l'air bien compliqué tout ça. Et avec toute les pages qu'il faut faire, ça risque de prendre un certain temps. Je manque de courage en pensant que c'est juste pour ajouter une ligne dans l'historique des versions. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 16 février 2026 à 14:51 (CET) ::::::::::Finalement, dans l'exemple que j'ai pris, la fusion n'avait aucun sens puisque le contenu du doublon que j'ai finalement supprimé était intégralement repris dans l'autre doublon par le même auteur. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 22 août 2026 à 23:55 (CEST) :::::::::::Désolé @[[Utilisateur:Xhungab|Xhungab]]. Je ne comprends rien à l'outil de fusion et je ne trouve rien qui me permet de comprendre sur le net. Une vidéo avec exemple des différents cas de figure serait bien, mais je ne trouve pas. Je vais donc laisser ce travail à ceux qui savent utiliser l'outil. :::::::::::En revanche, j'ai remarqué que certains doublons, comme dit précédemment, ne nécessitent pas de fusion puisqu'il s'agit de pages qui ont été créées sans aucune autre modification, puis, copiées par le même auteur dans une autre page créée avec un autre nom, au lieu de faire un renommage. Il faudrait donc que je prenne le temps de parcourir la liste pour repérer ces cas de figure avant de supprimer le premier doublon. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 23 août 2026 à 01:27 (CEST) ::::::::::::Merci pour ton aide. Actuellement ces pages ont été en quelque sorte neutralisées. Donc fais de ton mieux, mais ne te tracasse pas elles ne posent plus de problème. Encore merci [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 23 août 2026 à 09:13 (CEST) :::::::::::::OK @[[Utilisateur:Xhungab|Xhungab]], merci pour l'info. Au fait comment as-tu fais pour les détecter ? En parcourant les pages de la catégorie orphelines et en vérifiant celles qui sont doublons ? [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 23 août 2026 à 12:36 (CEST) ::::::::::::::Dans la catégorie pages orphelines, il y avait un lien sur une page. ::::::::::::::Je choisissais une phrase et je recherchais avec la fonction recherche de wikibook où se trouvait cette phrase. Si c'était un doublon, j'avais un lien vers la nouvelle page. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 23 août 2026 à 12:48 (CEST) :::::::::::::::Intelligent. Tu poserais pas ta candidature d'admin !? Ici, avec @[[Utilisateur:Fourmidable|Fourmidable]] et puis sur wikiversité aussi. Ça pourrait aider pour le projet des 20 ans., si tu comote toujours t'investir. On est en sous effectif dans les deux projects et les communautés me semblent plus ouverte que sur Wikipédia. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 23 août 2026 à 13:48 (CEST) ::::::::::::::::Je ne souhaite pas devenir un administrateur. Pourquoi ? Si on me donne du pouvoir, je l'utiliserais. Par la suite, ceux qui m'ont accordé le pouvoir auront le choix entre pleurer, nous quitter ou me mettre en arrêt de travail. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 23 août 2026 à 14:59 (CEST) :::::::::::::::::Ah ah ah. T'es si foireux que ça !? Ou peut-être as-tu eu des expériences antérieures ? Dans tous les cas, c'est super de ta part de pouvoir t'auto évaluer. :::::::::::::::::Ceci dit, le système de demander au administrateurs de faire des tâches qu'il faut expliquer est une perte de temps et d'efficacité. On pourrait d'ailleurs penser à attribuer les droits temporairement pour effectuer certaines tâches. Je me demande si ça se fait déja quelque part ? Si oui, on pourrait s'en inspirer pour mettre le système en application sur Wikilibres et Wikobersité. T'en penses quoi @[[Utilisateur:Xhungab|Xhungab]] ? [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 23 août 2026 à 15:40 (CEST) ::::::::::::::::::Oui, je pourrais obtenir des droits temporaires par rapport à un projet. Par exemple 2 projets ::::::::::::::::::Premier projet : Supprimer toutes les feuilles volantes. Ce sont des feuilles tests très bien faites mais qui n'appartiennent à aucun livre. Je crois qu'il y a actuellement, Feuilles volantes – 54 P. :::::::::::::::::: :::::::::::::::::: Deuxième projet : [https://fr.wikibooks.org/wiki/Sp%C3%A9cial:Pages_anciennes Les pages les plus anciennement modifiées] ::::::::::::::::::Supprimer tous les livres entre 2006 et 2010 qui sont à l'abandon. Parfois il sera possible de sauvegarder un livre en supprimant les chapitres vides, ce qui nous permettra de créer un livre complet. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 23 août 2026 à 16:19 (CEST) Donc, tu as du temps à consacrer à ce genre de maintenance ? Ce serait bête de ne pas en profiter. Reste à voir à présent si les autres administrateurs et bureaucrates seraient d'accord de t'octroyer les droits, tout en approuvant les deux projets que tu proposes. Cependant, je ne suis pas un grand fan des suppressions, je préfère de loin renommer les pages pour les placer en dehors de l'espace principal de sorte à ce que les auteurs puissent toujours les retrouver. Pour ça, un bon espace, c'est les sous-pages des principaux auteurs. Cela pourrait ainsi s'appliquer aux feuilles volantes et aux projets d'écriture abandonnés qui sont suffisamment développés pour justifier une conservation. L'avantage de cette méthode est qu'elle ne demande pas d'avoir les outils d'administrateurs. Faudrait voir maintenant dans quel cas on supprime et dans quel cas on renomme. On pourrait par exemple définir un nombre d'octets ou de caractères, question d'avoir un critère standard et accepté par tous. Qu'en penses-tu [[Utilisateur:Xhungab|Xhungab]] ? :En fait je préfère continuer comme actuellement. Proposer mes services pour améliorer wikibook sans avoir aucun droit. De cette manière, si je propose quelque chose d'incongru, comme cela est déjà arrivé, il suffit de neutraliser ma demande. Merci à ceux qui me suivent sur ce travail.[[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 24 août 2026 à 01:52 (CEST) :Je comprends @[[Utilisateur:Xhungab|Xhungab]]. Mais précisément, déplacer une page à l'abandon dans une sous-page l'espace utilisateur de l'auteur principal, cela ne demande justement aucun droit et tout le monde peut le faire, toi en premier puisque tu sembles actif à ce niveau. C'est pour ça que je te demande ton avis : que penses-tu de l'idée de déplacer les pages comme je viens de le décrire, plutôt que de les supprimer ? Je viens de soumettre l'idée dans une nouvelle discussion ci-dessous. Tu peux donner ton avis là-bas si tu veux. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 24 août 2026 à 02:27 (CEST) == archive.today == ''Voir [[:w:Wikipédia:Le Bistro/21 février 2026#archive.today]].'' Ce site pose apparemment des problèmes de sécurité, il faudrait le remplacer au plus vite si possible, et sinon désactiver les liens. [[Utilisateur:SyntaxTerror|SyntaxTerror]] ([[Discussion utilisateur:SyntaxTerror|discussion]]) 21 février 2026 à 13:07 (CET) :Salut SyntaxTerror, :* 0 liens vers archive.today : [https://fr.wikibooks.org/wiki/Sp%C3%A9cial:Recherche_de_lien?target=archive.today] :* <s>18</s> 0 liens vers archive.is [https://fr.wikibooks.org/wiki/Sp%C3%A9cial:Recherche_de_lien?target=archive.is] :* 0 liens vers archive.li [https://fr.wikibooks.org/wiki/Sp%C3%A9cial:Recherche_de_lien?target=archive.li] :* 0 liens vers archive.ec [https://fr.wikibooks.org/wiki/Sp%C3%A9cial:Recherche_de_lien?target=archive.ec] :* 0 liens vers archive.ph [https://fr.wikibooks.org/wiki/Sp%C3%A9cial:Recherche_de_lien?target=archive.ph] :* 0 liens vers archive.fo [https://fr.wikibooks.org/wiki/Sp%C3%A9cial:Recherche_de_lien?target=archive.fo] :* 0 liens vers archive.md [https://fr.wikibooks.org/wiki/Sp%C3%A9cial:Recherche_de_lien?target=archive.md] :* <s>1</s> 0 liens vers archive.vn [https://fr.wikibooks.org/wiki/Sp%C3%A9cial:Recherche_de_lien?target=archive.vn] :* 0 liens vers archive.closed.social [https://fr.wikibooks.org/wiki/Sp%C3%A9cial:Recherche_de_lien?target=archive.closed.social] :--&nbsp;◄&nbsp;[[Utilisateur:DavidL|'''D'''avid&nbsp;'''L''']]&nbsp;•&nbsp;[[Discussion Utilisateur:DavidL|discuter]]&nbsp;► 21 février 2026 à 16:14 (CET) ::Merci pour vos efforts ! [[Utilisateur:Fourmidable|Fourmidable]] ([[Discussion utilisateur:Fourmidable|discussion]]) 12 mai 2026 à 16:22 (CEST) == demande aide pour sortir du mode "ébauche" == je viens de terminer mon livre ... ci dessous. J'ai compris qu'il fallait le signaler comme n'étant plus au stade "ébauche" commente faire Merci https://fr.wikibooks.org/w/index.php?title=Essai_pour_un_mod%C3%A8le_de_psychisme_objectif&action=info#mw-pageinfo-header-basic [[Utilisateur:Clopeau|Clopeau]] ([[Discussion utilisateur:Clopeau|discussion]]) 17 mars 2026 à 08:20 (CET) :Bonjour, quand je regarde ''[[Essai pour un modèle de psychisme objectif]]'', sa complétude permettrait en effet de le sortir des ébauches, mais il semble plutôt d'agir d'un travail de recherche que d'un livre pédagogique sur un sujet sourcé comme reconnu. :Donc en vertu de [[Wikilivres:Principes fondateurs|notre charte]], je propose de le déplacer sur {{WV|Recherche:Accueil}}. [[Utilisateur:JackPotte|JackPotte]] ([[Discussion utilisateur:JackPotte|<span style="color:#FF6600">$</span>♠]]) 18 mars 2026 à 09:06 (CET) == Don à wikipédia depuis wikilivres == {{BlocCitation|La Wikimedia Foundation est l’organisation à but non lucratif qui soutient Wikipédia, '''les autres sites de connaissance libre de Wikimédia''', et sa mission de connaissance libre pour tous.|auteur=[https://wikimediafoundation.org/fr/give/donor-frequently-asked-questions/ FAQ], {{g|Qu'est-ce-que la Wikimedia Foundation ?}}}} Le lien de donation https://donate.wikimedia.org/w/index.php?title=Special:LandingPage&country=FR&uselang=fr&wmf_medium=sidebar&wmf_source=donate&wmf_campaign=fr.wikibooks.org '''ne parle que de wikipédia'''. '''Où sont passés les autres projets ?''' Vont-ils être fermés sauvagement par la fondation comme Wikinews récemment ? La suite au prochain épisode d{{'}}''Highlander-Wikimedia'' : {{g|À la fin, un seul ''projet'' survivra.}} ... &nbsp;◄&nbsp;[[Utilisateur:DavidL|'''D'''avid&nbsp;'''L''']]&nbsp;•&nbsp;[[Discussion Utilisateur:DavidL|discuter]]&nbsp;► 12 mai 2026 à 11:26 (CEST) :Bonjour, :Je pense personnellement que les wikis ont des problèmes. :Il y a une vingtaine d'années, il représentait l'innovation la plus ambitieuse. :Aujourd'hui, les wikis me semblent dépassés de tous les côtés. :           wiki = Erreurs 404 ("Page introuvable") :Problèmes: :* 568 livres déclarés. 50 livres terminés (combien sont obsolètes) :* 21 527 pages déclarées. 20 000 pages orphelines. (Papa t'est où?) :Solution: :* Où sont les cours sur YouTube qui utilisent un wikibook comme référence? :Voilà mon idée sur les wiki. :* https://fr.wikiversity.org/wiki/Facult%C3%A9:Droit :* https://fr.wikiversity.org/wiki/D%C3%A9partement:Introduction_au_droit :<nowiki>~~~~</nowiki> [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 12 mai 2026 à 13:50 (CEST) ::Ce serait intéressant de mentionner tous les projets actifs oui. [[Utilisateur:Fourmidable|Fourmidable]] ([[Discussion utilisateur:Fourmidable|discussion]]) 12 mai 2026 à 16:23 (CEST) ::: En même temps, le message est plutôt franc étant donné que l'argent des dons n'est pas investi dans le développement de Wikilivres (cf [[:meta:Community Wishlist Survey]] et les réponses à nos tickets Phabricator). ::: Par ailleurs, concernant la qualité des contenus, cela a toujours était un sujet depuis le début, et nous disposons tout de même de plusieurs pages spéciales dans [[Wikilivres:Maintenance]] pour y remédier afin que le lecteur n'ait pas la sensation de se faire empapaouter en se retrouvant sur des ébauches bien placées dans Google. [[Utilisateur:JackPotte|JackPotte]] ([[Discussion utilisateur:JackPotte|<span style="color:#FF6600">$</span>♠]]) 13 mai 2026 à 08:48 (CEST) == La catégorie SI est pleine ! == Bonjour tous, Pour info, la [[:Catégorie:Suppressions immédiates demandées]] contient 9 pages. Wikilivresquement, [[Utilisateur:Fourmidable|Fourmidable]] ([[Discussion utilisateur:Fourmidable|discussion]]) 14 juin 2026 à 15:36 (CEST) :Elle est maintenant vide, merci à ceux qui s'y sont collés ! {{Sourire}} [[Utilisateur:Fourmidable|Fourmidable]] ([[Discussion utilisateur:Fourmidable|discussion]]) 24 juin 2026 à 17:17 (CEST) == Un RAW bien frais pour juillet == {| style="width:100%;" | valign="top" align="center" style="border:1px gray solid; padding:1em;" | {| align="center" |- | style="text-align: center;" | <div style="background-color:#177860; border-radius:.2em; color:#FFF;padding: 1em;"> <div style="font-family: Century Gothic; font-size:4.5em; line-height:120%;text-align:center;color: #fff;">RAW</div> <div style="margin-bottom:1.5em;text-align:center; color: #fff; font-style:italic;">Regards sur l’actualité du mouvement Wikimédia.</div> <div style="text-align: center;">{{#ifeq:{{FULLPAGENAME}}|Wikipédia:RAW/Rédaction}}</div> </div><br /> <hr /> <div style="font-size:12pt; font-family:Times New Roman; text-align:center;">[[w:fr:Wikipédia:RAW/2026-07-05|<span style="color:darkslategray;">Le numéro de juillet 2026 est sorti.</span>]]</div> <hr /><br /> |- style="text-align: center;" | <span style="font-size:12pt; font-family:Times New Roman;"> '''❖ Au menu de ce numéro ❖'''</span> |- style="font-size:10pt; font-family:Times New Roman; text-align:center;" | <div style="text-align:center; column-count:1; column-width:28em; vertical-align:top;"> '''L'Édito'''</br> [[w:fr:Wikipédia:RAW/2026-07-05#L'Édito|Fêtons notre mouvement !]] '''Échos francophones'''</br> [[w:fr:Wikipédia:RAW/2026-07-05#Résidence|Un nouveau vigneron dans la résidence]]</br> [[w:fr:Wikipédia:RAW/2026-07-05#Logo_WP25|Wikipédia en français aux couleurs des 25 ans]]</br> [[w:fr:Wikipédia:RAW/2026-07-05#DAF|Wikipédia dans le ''Dictionnaire de l'Académie française'']]</br> [[w:fr:Wikipédia:RAW/2026-07-05#Millésime_Wiktionnaire|Quelles sont les nouvelles entrées en français sur le Wiktionnaire ?]]</br> [[w:fr:Wikipédia:RAW/2026-07-05#ADS|Relance de l'Article de la semaine]]</br> [[w:fr:Wikipédia:RAW/2026-07-05#sans_pagEs|Le projet des sans pagEs fête ses dix ans]]</br> [[w:fr:Wikipédia:RAW/2026-07-05#contributions_rémunérées|Faut-il faire évoluer les règles sur les contributions rémunérées ?]]</br> [[w:fr:Wikipédia:RAW/2026-07-05#wikification|Le mois de la wikification fait son retour pour une septième édition]] '''Ailleurs dans le mouvement'''</br> [[w:fr:Wikipédia:RAW/2026-07-05#Txikipedia|Txikipedia franchit le cap des 10 000 articles]]</br> [[w:fr:Wikipédia:RAW/2026-07-05#Concours_santé_2026|Un concours sur la santé]]</br> [[w:fr:Wikipédia:RAW/2026-07-05#Commons_et_IA|Commons se penche sur l'IA]] '''Nouveautés techniques et outils'''</br> [[w:fr:Wikipédia:RAW/2026-07-05#VIZWP|VIZWP : un outil pour visualiser les lacunes dans les sujets climatiques sur Wikipédia, mais pas que…]]</br> [[w:fr:Wikipédia:RAW/2026-07-05#AI_Source_Verification|Examiner la vérifiabilité grâce à l'IA ?]]</br> [[w:fr:Wikipédia:RAW/2026-07-05#Icônes|Les icônes de l'interface ont changé]]</br> [[w:fr:Wikipédia:RAW/2026-07-05#Sous-références|Ça y est, les sous-références sont là]]</br> [[w:fr:Wikipédia:RAW/2026-07-05#ConvoCompass|ConvoCompass, pour moins d'attaques personnelles]]</br> [[w:fr:Wikipédia:RAW/2026-07-05#LSV-Tools|LSV-Tools : faciliter la gestion des anecdotes « Le saviez-vous ? »]]</br> [[w:fr:Wikipédia:RAW/2026-07-05#LegendAndDot|Semi-automatiser l'ajout de ponctuation dans les légendes d'images]] '''Du côté de la recherche'''</br> [[w:fr:Wikipédia:RAW/2026-07-05#COMSAV|COMSAV : la contribution à Wikipédia fait-elle d'un ensemble d'individus un ensemble de pairs ?]]</br> [[w:fr:Wikipédia:RAW/2026-07-05#JourneyMap|Où commence la contribution et où s'arrête-t-elle ?]] '''Le POV'''</br> [[w:fr:Wikipédia:RAW/2026-07-05#Le_POV|Guide survie pour la Wikimania 2026]] par Trizek '''L'Atelier'''</br> [[w:fr:Wikipédia:RAW/2026-07-05#L'Atelier|Le point sur la diversité de genre dans les articles]] par PAC2 '''L'Analyse'''</br> [[w:fr:Wikipédia:RAW/2026-07-05#L'Analyse|Larry Sanger is back... and fired!]] par Madelgarius, Trizek et Jules* '''L'interview'''</br> [[w:fr:Wikipédia:RAW/2026-07-05#L'interview|Pronoia : de Byzance aux réseaux sociaux, un œil sur Wikipédia et le monde]] par Antimuonium '''Le wik’hit-parade'''</br> [[w:fr:Wikipédia:RAW/2026-07-05#Wikit|Articles les plus vus en juin 2026]] par Ælfgar </div> |- | style="font-family:Times New Roman; text-align:center; font-size:90%;" | [[w:fr:Wikipédia:RAW/2026-07-05|Lire tout le numéro]] |- | valign="top" colspan="2" style="padding:0.5em; font-family:Times New Roman;text-align:center; font-size:100%;" | Venez rédiger le prochain numéro, y parler de ce qui se fait dans votre communauté : [[w:fr:Discussion_Wikipédia:RAW/Rédaction|Salle de rédaction]].</br>Les anciens numéros sont retrouvables [[w:fr:Modèle:Palette RAW|ici]]. |- |} |} <div style="margin-top:10px; font-size:90%; padding-left:5px; font-family:Georgia, Palatino, Palatino Linotype, Times, Times New Roman, serif;">[[w:fr:Wikipédia:RAW/Inscription|Abonnez-vous !]] [[Utilisateur:L&#39;embellie|L&#39;embellie]] ([[Discussion utilisateur:L&#39;embellie|discussion]]) 5 juillet 2026 à 01:53 (CEST)</div> == Les RAW en mode fiesta sur la Wikimania == <div style="background-color:#177860; border-radius:.2em; color:#FFF;padding: 1em;"> <div style="font-family: Century Gothic; font-size:3em; line-height:120%;text-align:center;color: #fff;">RAW</div> <div style="margin-bottom:1.5em; margin-top:1em; text-align:center; color: #fff;">Le numéro d'août 2026 (n°295) est sorti.</div> <div class="center">[[:w:Wikipédia:RAW/2026-08-05|<span class="cdx-button cdx-button--fake-button cdx-button--fake-button--enabled" style="white-space:break-spaces; text-align:center; gap:0;"role="button" aria-disabled="false">Lire l'infolettre</span>]]</div></div> '''Un numéro exceptionnel''' La conférence Wikimania s'est achevée le 25 juillet. Que vous ressentiez de la frustration (si vous n’avez pas pu y assister) ou du [[:w:Wikipédia:Wikispleen|wikispleen]] (si vous y étiez et que vous avez du mal à digérer que ce soit déjà fini), l’équipe des RAW n’a pas ménagé ses efforts pour vous proposer un numéro XXL qui revient en long, en large et en travers sur cet événement qui le mérite bien. Comptes-rendus, témoignages, entretiens, il y en aura pour tous les goûts ! Le numéro est aussi exceptionnel par le nombre de rédactrices et de rédacteurs qui y ont participé {{incise|merci !|stop}} Au-delà de Wikimania, l’actualité wikimédienne est loin d’être paisible cet été. Nous vous proposons donc aussi de revenir en détail sur la crise qui secoue la fondation Wikimédia autour de la reconnaissance du syndicat Wiki Workers United (WWU). Nous aurons probablement l’occasion de couvrir la suite de cette histoire dans le prochain numéro. D’ici là, bonne lecture ! ---- Au menu de ce numéro : '''<span style="margin:0;background:var(--color-success, #177860);padding:3px;color:white">En bref</span>''' — Des actualités francophones et du mouvement Wikimédia et des nouveaux outils '''<span style="margin:0;background:var(--color-success, #177860);padding:3px;color:white">Dossier spécial Wikimania</span>''' '''<span style="margin:0;background:var(--background-color-success-subtle, #dff2eb);padding:3px;color:black">Les POV</span>'''<br>— Ma Wikimania : la joie, l'inquiétude et la raison d'être<br>— Témoignages : regards sur la Wikimania '''<span style="margin:0;background:var(--background-color-success-subtle, #dff2eb);padding:3px;color:black">L'Analyse</span>''' — La crise s'intensifie après le refus de la WMF de reconnaître le syndicat WWU '''<span style="margin:0;background:var(--background-color-success-subtle, #dff2eb);padding:3px;color:black">Les Comptes-rendus</span>'''<br>— Une Wikimania sous le signe de la jeunesse<br>— Sur les conséquences de la définition de « wikimédien·ne »<br>— Petit tour d’horizon de la créativité wikimédienne<br>— CultureStrong : respecter les savoirs des Premières Nations sur les plateformes Wikimédia<br>— Des événements techniques à Wikimania '''<span style="margin:0;background:var(--background-color-success-subtle, #dff2eb);padding:3px;color:black">Les Interviews</span>'''<br>— Ses goûts, sa Wikimania, sa prochaine tournée... : Baby Globe nous dévoile tout !<br>— Jules*, notre Wikimédien de l'année : comment rendre compte du changement climatique tout en protégeant l'encyclopédie '''<span style="margin:0;background:var(--color-success, #177860);padding:3px;color:white">Le wik'hit parade</span>''' — Une analyse des articles les plus vus le mois dernier [[Fichier:Toicon-icon-sharp-corners-draw.svg|20px|classe=skin-invert-image]] Les RAW, c'est aussi vous : si un sujet vous tient à cœur, n'hésitez pas à proposer une idée d'article ou à participer à la rédaction du [[:w:Wikipédia:RAW/Rédaction|prochain numéro]]. Toutes les contributions sont les bienvenues, quel que soit votre wiki d'origine ! <span class="smiley">[[Image:Face-smile.svg|20px|Sourire|alt=Émoticône sourire]]</span> [[Utilisateur:Antimuonium|Antimuonium]] ([[Discussion utilisateur:Antimuonium|discussion]]) 5 août 2026 à 00:28 (CEST) == Bientôt les 20 ans de Wikiversité == Bonjour. Un petit message de « débauchage » dans le cadre d'un projet d'amélioration de Wikiversité. Comme dit la chanson : « On n'a pas tous les jours vingt ans ! » Plus d'infos [[v:Wikiversité:La_salle_café/août_2026#Améliorer_Wikiversité_pour_ses_vingt_ans_d'existence|dans la salle café du projet]]. Belle fin de journée à tous ! [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 16 août 2026 à 00:26 (CEST) == [[:Catégorie:Suppressions immédiates demandées|Série de demandes de suppression immédiate]] == Bonjour, La [[:Catégorie:Suppressions immédiates demandées]] est de nouveau pleine, mais cette fois, il semble qu'elle ait été remplie par le seul {{U|Xhungab}}. Cordialement, [[Utilisateur:Fourmidable|Fourmidable]] ([[Discussion utilisateur:Fourmidable|discussion]]) 16 août 2026 à 14:56 (CEST) :{{fait}} Bonjour, oui c'était effectivement un ensemble d'ébauches abandonnées. [[Utilisateur:JackPotte|JackPotte]] ([[Discussion utilisateur:JackPotte|<span style="color:#FF6600">$</span>♠]]) 17 août 2026 à 10:38 (CEST) ::Merci pour votre aide [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 17 août 2026 à 14:26 (CEST) == Deux idées à débattre == Bonjour. Vu le travail de traitement des pages et livres abandonnés et suite à une discussion avec @[[Utilisateur:Xhungab|Xhungab]] reprise ci-dessous, j'aimerais soumettre deux idées à la communauté Wikilivres. '''La première idée''' est de déplacer toutes les pages et livres dont le développement est abandonné depuis longtemps en sous-page de l'auteur principal tout en laissant un message sur sa page de discussion avec un lien pointant vers la sous-page en question. Ce message pouvant faire l'objet d'un modèle. Avantage : 1. Pas besoin d'être admin pour faire ce genre de maintenance. 2. Pas de risque de frustrer un auteur ni de perdre de vue des informations intéressantes. 3. Ceci dans le but d'améliorer l'apparence de Wikilivres au niveau du contenu visible sur l'espace de nom principal. Nécessité : 1. Créer un modèle à placer sur la page de discussion de l'auteur principal après déplacement. 2. Définir la durée d'abandon des pages à déplacer. '''La deuxième idée''' est de donner les outils d'administrateurs à des personnes qui ne veulent pas endosser cette responsabilité à long terme et de manière continue, mais qui sont d'accord d'en faire usage dans le cadre d'un projet de maintenance temporaire. Cela pourrait être utile pour @[[Utilisateur:Fourmidable|Fourmidable]] s'il le souhaite, à moins qu'il se décide à déposer sa candidature pour une nomination définitive. Ce qui serait une bonne idée, je trouve. Qu'en pensez-vous @[[Utilisateur:JackPotte|JackPotte]], @[[Utilisateur:DavidL|DavidL]], @[[Utilisateur:Grondin|Grondin]] ? Une prise de décision est-elle nécessaire si on décidait une mise en application ? Bien à vous tous, [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 24 août 2026 à 01:50 (CEST) :Bonjour, :J'ai proposé une liste de livres à supprimer :https://fr.wikibooks.org/wiki/Wikilivres:Demande_de_suppression_imm%C3%A9diate :Doit-on réellement les garder ? ;) [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 24 août 2026 à 10:21 (CEST) ::Quel est l'avantage de la suppression par rapport au déplacement @[[Utilisateur:Xhungab|Xhungab]]? [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 24 août 2026 à 10:56 (CEST) :::En déterminant un contenu minimum, on peut faire le tri de ce que l'on supprime et ce que l'on déplace. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 24 août 2026 à 10:58 (CEST) ::::Bonjour, ::::Les droits administrateurs temporaires sont difficiles à gérer : un bureaucrate peut attribuer le status administrateur mais pas le retirer. ::::Concernant les critères suppression ou renommage, je propose : ::::* (cas 1) Une page qui ne contient vraiment aucun texte (ex : seulement des liens rouges) --> suppression ::::* (cas 2) Une page avec quelques phrases significatives récupérables --> soit (a) tenter de fusionner dans un livre qui aborde le même sujet ou sujet connexe, soit (b) renommer en sous-page de l'auteur si aucun livre pour la fusion ne convient ::::* (cas 3) Une page avec plus de contenu que (cas 2) --> conserver ::::--&nbsp;◄&nbsp;[[Utilisateur:DavidL|'''D'''avid&nbsp;'''L''']]&nbsp;•&nbsp;[[Discussion Utilisateur:DavidL|discuter]]&nbsp;► 24 août 2026 à 19:33 (CEST) :::::Salut @[[Utilisateur:DavidL|DavidL]]. Oui, c'est raisonnable. Reste que la fusion, c'est compliqué. J'ai essayé, mais je ne comprends rien. Une vidéo m'aiderait à comprendre, mais j'en ai pas trouvé. Ensuite, si on garde, genre un livre abandonné depuis plus de trois ans avec un chapitre entamé sur cinq prévus, par exemple, on le laisse dans l'espace principal, ou on le déplace vers l'espace utilisateur de l'auteur ? [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 25 août 2026 à 00:38 (CEST) ::::::Pour ce cas je propose de le fusionner si possible, comme (cas 2)(a) ; sinon le conserver, comme (cas3) afin qu'un utilisateur motivé puisse reprendre le livre. ::::::--&nbsp;◄&nbsp;[[Utilisateur:DavidL|'''D'''avid&nbsp;'''L''']]&nbsp;•&nbsp;[[Discussion Utilisateur:DavidL|discuter]]&nbsp;► 25 août 2026 à 08:36 (CEST) :::::::Ok. Es-tu à l'aise avec la fusion @[[Utilisateur:DavidL|DavidL]] ? Car c'est une solution intéressante, mais je n'y arrive pas. Il y a toujours un message que je ne comprends pas et suite à quoi je préfère arrêter. Serais-tu dispo pour m'expliquer comment faire durant un appel vidéo ? [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 26 août 2026 à 01:30 (CEST) ::::::::Bonjour {{Mention|Lionel Scheepmans}}, je suis très favorable à tes deux idées ! [[Utilisateur:Fourmidable|Fourmidable]] ([[Discussion utilisateur:Fourmidable|discussion]]) 26 août 2026 à 15:39 (CEST) :::::::::Ok @[[Utilisateur:Fourmidable|Fourmidable]], je me dis qu'il serait encore bien d'avoir les avis de @[[Utilisateur:JackPotte|JackPotte]], voir de @[[Utilisateur:Grondin|Grondin]], s'ils trouvent le temps nécessaire pour le faire. Et puis, on peut penser tout doucement à créer une page pour fixer des décisions en votant si nécessaire pour confirmer l'adhésion générale ou grandement majoritaire. :::::::::C'est curieux, je voulais travailler sur Wikiversité et finalement je me trouve plus actif sur Wikilivres. Mais les enjeux sont les mêmes et beaucoup de personnes actives ici le sont aussi sur Wikiversité, y compris parmi les administrateurs. En avançant dans Wikilivres, j'ai donc aussi l'impression que l'on avance sur Wikiversité. :::::::::Au fait, fourmidable ? Tu ne serais pas intéressé, toi, de prendre un balai dans Wikilivres ? [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 26 août 2026 à 17:27 (CEST) == Demande d'aide pour amélioré Wikbook. Merci == * Actuellement j'ai proposé mes services pour améliorer wikibook. J'ai créé un partenariat informel avec des administrateurs, je propose des pages et des livres à supprimer. Ils décident soit de suivre ma demande, soit de la neutraliser. Je les remercie pour leur patience et leur aide. - https://fr.wikibooks.org/wiki/Wikilivres:Demande_de_suppression_imm%C3%A9diate *Certain livres semblent trop avancé pour suivre cette procédure. - https://fr.wikibooks.org/wiki/Wikilivres:Demandes_de_suppression/2026 Dans cette page, je propose des livres trop avancés pour être traités rapidement. Il faut un vote pour que les demandes soient acceptées. Aujourd'hui j'ai besoin de vous pour faire avancer mes propositions, sur des livres peut-être intéressants mais abandonnés depuis plus de trois ans, cinq ans, dix ans, vingt ans. Si vous appréciez mon dévouement pour Wikibook et que vous souhaitez m'aider, ''il suffit chaque semaine de rajouter la commande'' {{VoteSupprimer}} ''sur les livres que je propose''. '''Entré {{}} puis à l'intérieur des parentthèses VoteSupprimer''' et signer votre accord. Merci pour votre aide. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 25 août 2026 à 10:33 (CEST) :Salut @[[Utilisateur:Xhungab|Xhungab]], merci à toi pour ton engagement ! J'ai supprimé les pages qui étaient pratiquement vides dans ta liste. J'ai laissé les autres car la proposition de fusion est invoquée par @[[Utilisateur:DavidL|DavidL]] pour les pages avec du contenu. Malheureusement , je ne comprends pas comment fonctionne l'outil de fusion. J'espère que quelqu'un voudra bien m'expliquer le fonctionnement via un appel vidéo. Car je ne comprends pas les explications écrites. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 26 août 2026 à 01:53 (CEST) ::Bonjour, Merci pour ton aide. ::. ::* J'ai travaillé sur la liste des pages les plus anciennement modifiées de l'année 2006. J'ai proposé tous les livres en errance à la suppression par vote. ::- https://fr.wikibooks.org/wiki/Wikilivres:Demandes_de_suppression/2026 ::Je vais attendre qu'elle soit traitée. Soit en supprimant ces livres soit en supprimant ma demande. Ensuite je ferai l'année 2007. ::. ::*Tu m'a posé une question. ::Quel est l'avantage de la suppression par rapport au déplacement ? ::Je vais essayer de te répondre dans mon prochain message. (Ce sera juste un délire d'un mois d'août un peu chaud). [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 26 août 2026 à 09:20 (CEST) :::C'est motivant de voir des gens motivés =) :::Prend ton temps, il n'y a aucune échéance dans les projets Wikimédia, c'est un processus en continu qui peut s'étendre à plusieurs générations... Et si c'est plus facile pour toi de passer à l'oral, sache que je suis partant pour une discussion audio ou vidéo. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 26 août 2026 à 13:35 (CEST) == J'ai fait un rêve. == J'ai fait un rêve. Un lieu où des amateurs passionnés proposeraient de partager leurs travaux avec des étudiants curieux. Pas les meilleurs travaux du monde. Mais pas les pires non plus. Un endroit où les anciens passeraient le relais à ceux qui présentent leurs avenirs. Ce serait une petite bibliothèque avec une cinquantaine de livres disponibles à l'étude. Cinq ou six livres en construction que les étudiants pourraient travailler avec l'auteur. Tous les ans quelques livres supplémentaires viendraient enrichir ce lieu d'étude. Aujourd'hui, il existe Wikibook. Un lieu où des auteurs malveillants prouvent que le travail collaboratif ne fonctionne pas. Ils créent des livres parfaitement construits de quelques chapitres puis les abandonnent sans scrupule. Ces livres moisissent depuis cinq, dix, quinze, vingt ans. Wikibook c'est: * La bibliothèque de livres pédagogiques libres que chacun peut améliorer. * 569 livres contenant 22 112 pages. . * 569 livres dont 450 livres à l'abandon depuis cinq, dix, quinze, vingt ans. Oui, aujourd'hui Wikibook peut changer. Wikibook va changer. Wikibook pour les étudiants va renaître de ses cendres. Les cendres des vieux livres abandonnés. Les auteurs devront faire honnêtement leur travail, ou les étudiants feront le leur. Ceci n'est juste qu'un rêve dans un mois d'août un peu plus chaud que d'habitude. Merci pour m'avoir suivi jusqu'ici. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 26 août 2026 à 10:13 (CEST) :Je trouve que c'est un très beau rêve @[[Utilisateur:Xhungab|Xhungab]]. Dans ce rêve, on peut voir la partie remplie du verre au lieu de la partie vide. Sur Wikilivres, il y a plus de 100 ouvrages terminés entièrement libres d'usage et toujours améliorables collectivement. Deux fois plus que la cinquantaine de livres dont tu parles dans ton rêve. :C'est normal d'abandonner un projet d'écriture. La vie est tellement imprévisible. Les ressources en temps et énergie tant limitées. Combien de personnes ont commencé un livre sur papier ou sur leur ordi sans jamais le terminer ? :C'est pareil sur Wikilivres, sauf que c'est visible aux yeux de tous. :Quand on commence un truc, c'est jamais dans l'intention de l'arrêter. Si on arrête, c'est qu'il y a des raisons à cela. Je n'ai donc pas envie de voir dans ces abandons des actes de malveillance, ni de critiquer leurs auteurs. J'ai d'ailleurs moi-même des projets que je n'arrive pas à concrétiser (exemple). :Je pense au contraire que ce n'est pas un problème à partir du moment où nous avons la liberté et les outils (communication, suppression, fusion) qui nous permettent de faire le tri entre ce qui est terminé, voire même ce qui est de qualité, et le reste. :Ce rêve est donc possible et mes démarches pour devenir wikimédien en résidence à l'université sont une démarche qui va dans ce sens. J'ai d'ailleurs apporté les preuves que l'intégration des projets Wikimédia dans le processus d'apprentissage universitaire est non seulement possible, mais hyper efficace. Malheureusement, j'ai toujours l'impression que cela n'intéresse personne. Ni à l'Unif, ni dans Wikimédia. Mais je ne désespère pas. Les changements de paradigmes sont lents en raison des habitudes. C'est une fatalité chez l'humain. Il faut faire avec. :Mais donc. Concentrons-nous sur la manière d'organiser notre bibliothèque pour que les visiteurs puissent confortablement voir ce qui s'y trouve en séparant, livres terminés, livres de qualité (cela se fait bien avec les articles de Wikipédia), livres en cours d'édition et livres abandonnés, etc. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 26 août 2026 à 13:32 (CEST) ::Je pense que nos points de vue sont irréconciliables en toute amitié. Pour moi, Wikibook est une bibliothèque de livres pédagogiques libres que chacun peut améliorer. Soit on peut mettre un étudiant sur un livre. Soit ce livre est inutile (Je parle de livre de plus de trois ans). ::. ::Wikibook n'est pas un entrepôt pour les auteurs. C'est une bibliothèque.[[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 26 août 2026 à 14:57 (CEST) :::@[[Utilisateur:Xhungab|Xhungab]], c'est bien que l'on ne soit pas d'accord, car cela permet d'avancer vers une solution que tout le monde pourra approuver. C'est le principe du consensus et cela prend du temps en discussion. C'est normal et sain. En de plus, ce qui est dit ici est tout à fait pertinent pour Wikiversité qui est confronté à des problèmes similaires que Wikilivres. :::J'exprime mes idées ci-dessous mes idées autrement pour voir si cela peut se rapprocher de ta propre vision des choses. :::J'imagine la bibliothèque Wikilivres avec trois pièces : :::# Une pièce principale à l'entrée qui reprend tous les livres terminés et prêts à l'emploi par une personne désireuse de les utiliser dans le cadre d'un apprentissage. Comme dans toutes les bonnes bibliothèques, ces ouvrages sont rangés par thèmes et certains d'entre eux sont mis en évidence en raison de leurs qualités, de leur pertinence par rapport à l'actualité, etc. :::# Une pièce archives à l'arrière du bâtiment (un espace de nom que l'on pourrait choisir parmi ceux existants ou créer si nécessaire) dans laquelle on entrepose toute une série de livres abandonnés mais dont le contenu est archivé dans le but d'une éventuelle réutilisation. Cette réutilisation, comme le suggère @[[Utilisateur:DavidL|DavidL]], peut s'effectuer dans le cadre d'une fusion du contenu abandonné avec un autre contenu abandonné, ou mieux encore, si cela se présente, avec un livre terminé dans lequel les informations utiles du document abandonné sont manquantes. À noter que cela représente beaucoup de travail, tant pour la relecture que pour la mise en œuvre des fusions, qui, à mes yeux de personne expérimentée dans l'usage de MediaWiki, est un processus compliqué. De plus, il ne peut être fait que par des personnes ayant les outils d'administrateur. C'est-à-dire quatre personnes, dont deux ou trois actives dans ces récentes discussions. :::# Dans la librairie Wikilivres, il y a ensuite un étage dans lequel chaque personne inscrite peut bénéficier d'un espace personnel, pour se présenter, stocker des informations, mais également travailler sur des projets personnels d'écriture. En tenant compte de ceci, on peut alors aussi imaginer de déplacer les ouvrages inachevés de la pièce principale à l'entrée de la librairie, vers ces différents locaux selon leurs auteurs respectifs. :::Face à ces trois espaces, la question qu'il reste à se poser est de savoir si et dans quelles condition, on peut déplacer les ouvrages inachevés vers une salle d'archive, ou dans les locaux personnels des auteurs ? Cela en signalant que ce déplacement peut être fait très facilement et par toutes les personnes qui le souhaitent, même sans inscription à la bibliothèque. Ce qui est un grand avantage par rapport à la fusion. :::Pendant ce temps, à l'accueil de la bibliothèque Wikibook, on invite les visiteurs à parcourir la pièce avec les ouvrages terminés, mais également la pièce archive, où ils peuvent trouver des ouvrages inachevés, au cas où ils veulent poursuivre leurs développements, ou trouver des ressources intéressantes pour leurs travaux personnels. On les informe ensuite aussi qu'avec leur inscription, ils bénéficient d'un local pour leurs activités personnelles et notamment pour y placer les ébauches d'ouvrages qu'ils désirent rédiger. :::Que penses-tu de mon propre rêve ? Est-il si différent du tien ? [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 26 août 2026 à 17:17 (CEST) ::::Et les autres ? @[[Utilisateur:DavidL|DavidL]], @[[Utilisateur:Fourmidable|Fourmidable]], @[[Utilisateur:JackPotte|JackPotte]], @[[Utilisateur:Grondin|Grondin]]. Que pensez-vous de nos rêves ? Et, si le mien vous parle, quelles seraient vos propositions pour orienter les déplacements de pages à l'abandon entre un espace d'archivage (qui pourrait être créé) et des espaces utilisateurs ? Le nombre d'octets ? La finalisation d'une section ou d'un chapitre ? Quelles autres possibilités ? [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 26 août 2026 à 17:37 (CEST) ::::: Bonjour, a priori ça doit se régler au cas par cas sur [[Wikilivres:Demandes de suppression]]. [[Utilisateur:JackPotte|JackPotte]] ([[Discussion utilisateur:JackPotte|<span style="color:#FF6600">$</span>♠]]) ::::Bonjour, ::::La structure de Wikibook est parfaite grâce à sa vitrine. Pour moi, il y a juste un problème sur les listes sur la droite : Arts, Loisir, Histoire, Langues, Technologie, Informatique, Sciences, Sciences humaines. Ces listes ne devraient donner accès qu'aux livres terminés. ::::Ces listes actuelles pourraient être caché dans un espace création qui pourrait être posé avec le lien Nouveauté en bas de la page. ::::Pour la gestion des livres, il suffirait qu'une ou deux personnes parcourent la bibliothèque et proposent quelques suppressions chaque semaine, pour en quelques mois éclaircir la situation. Je pense que l'on peut faire confiance aux administrateurs pour gérer la situation des livres, ceux qui sont à garder et les autres. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 26 août 2026 à 18:30 (CEST) :::::@[[Utilisateur:JackPotte|JackPotte]]. Le cas par cas est ce que nous faisons depuis toujours. Cela nous amène à la situation actuelle qui semble ne pas convenir à tous. En tout cas, c'est mon cas. Ici comme sur Wikiversité. Quand, en navigant sur le site, une personne tombe neuf fois sur dix sur des pages presque vides et inachevées, comme c'est le cas sur Wikilivre, elle se dit : c'est quoi ce site ? Elle passe alors à autre chose de plus vivant, genre https://zestedesavoir.com/ ou autre. Sans changements éditoriaux, je pense que la situation évoluera. :::::@[[Utilisateur:Xhungab|Xhungab]], on peut faire comme tu dis. Cependant, la charge de travail repose sur une double action, la proposition d'abord et le traitement ensuite. Un traitement qui finalement repose sur les épaules des administrateurs. Nous ne sommes que des bénévoles peu nombreux et pas toujours disponibles. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 26 août 2026 à 19:13 (CEST) :Créer un livre, le ranger, en établir un plan, le catégoriser n'est pas un travail négligeable... Un travail que d'autres personnes intéressées pour écrire sur le sujet n'ont pas à refaire 🤷‍♂️ ... == Petite proposition indécente à Lionel == Bonjour Lionel, Je souhaiterais te proposer un petit projet. a) Dans la page principale de wikibook tu créés une page "Espace pour les auteurs". Tu insères cette page devant le lien "Nouveautés". . b) Tu copies dans cette page la liste des liens qui se trouvent en haut à droite dans la nouvelle page. (Arts, Loisirs, ...) Tu ne les modifies pas dans la page principale. . c) Dans la nouvelle page tu peux écrire un texte du genre. Dans cette page, vous trouverez la liste de tous les livres disponibles sur Wikibook. Vous trouverez en particulier des livres en construction et vous pourrez entrer en contact avec les auteurs. Vous trouverez aussi des livres en construction sans auteur actif que vous pourrez compléter. Cela est la première étape, si l'aventure t'intéresse. En fait tu ajoutes juste une page, que tu pourras supprimer si les besoins se font sentir. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 27 août 2026 à 12:25 (CEST) :La proposition est très décente @[[Utilisateur:Xhungab|Xhungab]] :Mais avant toute choses le plus prudent est de lancer une prise dé décision pour s'assurer d'un concesnsus en faveurs des changements dont nous débatons actuellement. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 27 août 2026 à 15:40 (CEST) ::Toutes ces actions seront réversibles. On ajoute une page. Un problème, on la supprime. ::On fait rapidement les changements, on attend trois jours. En cas de problème, on supprime tout. Il suffira de s'excuser et de dire que l'on ne savait pas que l'on n'avait pas le droit. :: Un espace pour les lecteurs et un espace pour les auteurs séparés. ::[https://fr.wikibooks.org/wiki/Utilisateur:Xhungab Nouvelle page wikibook][[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 27 août 2026 à 16:49 (CEST) :@[[Utilisateur:Xhungab|Xhungab]] , ma grand mère disait : faire et défaire, c'est toujours travailler. Pour les grand changements, on a coutume de faire un prise de décision composée des propositions, suivies de débats et d'un vote. :En plus, certaines décisions nécessite un changement technique et ce n'est pas toujours facilement réversible. Comme créer un nouvel espace de nom pour y transfèrer une catégorie de pages [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 28 août 2026 à 03:07 (CEST) ::Merci pour ton aide. Tu comprends maintenant pourquoi toi tu es un administrateur et moi pas. ::. ::'''Oui, aujourd'hui Wikibook peut changer. Wikibook va changer.''' ::. ::* '''Le changement c'est Maintenant :''' [https://fr.wikibooks.org/wiki/Utilisateur:Xhungab '''Wikibook'''] ::[[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 28 août 2026 à 08:33 (CEST) :::Ton enthousiasme fait plaisir @[[Utilisateur:Xhungab|Xhungab]], et je serais en faveur d'un style plus épuré de notre page d'accueil en plus d'une méthodes de classement et de séparation des ouvrages terminé, en cours et abandonnés. Je pense qu'à terme, nous pourrions faire une proposition de changement à soumettre au reste de la communauté. Par chance, nous ne sommes pas nombreux, cela simplifire les discussions et le processus de décision. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 29 août 2026 à 19:26 (CEST) ::::Cette page [https://fr.wikibooks.org/wiki/Utilisateur:Xhungab '''Wikibook'''] propose la vitrine, les livres disponibles, les mini-livres disponibles pour les lecteurs. Les lecteurs ne seront en contact qu'avec les livres terminés. ::::. ::::En bas à côté des nouveautés, il y a l' '''[https://fr.wikibooks.org/wiki/Discussion_utilisateur:Xhungab Espace pour les auteurs]''', avec tous les livres du site rangés par thème ou par niveau d'avancement si on clique sur l'image. Sinon, il y a une liste de catégories art,... ::::. ::::Cette proposition sépare donc deux espaces. Un pour les lecteurs et un pour les auteurs. Il suffirait maintenant plus qu'à nettoyer l'espace auteurs. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 29 août 2026 à 20:22 (CEST) L'usage des catégories me semble aussi une manière très pratique de trier le contenu de Wikilivre. Malheureusement, ces pages ne sont pas très sexy du coup l'usage de type de code pourrait être utile : { {Boîte déroulante|titre=Cliquez ici pour voir les livres terminés|alignT=left|contenu={ { #categorytree:Livres terminés|mode=pages} } } } Avec le rendu ci-dessous : {{Boîte déroulante|titre=Cliquez ici pour voir les livres terminés|alignT=left|contenu={{ #categorytree:Livres terminés|mode=pages}}}} On pourrait ainsi imaginer d'intégrer directement ce code dans la page d'accueil pour afficher une boite déroulante au lieu d'envoyer le visiteur en dehors de la page d'accueil. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 29 août 2026 à 21:08 (CEST) :Tu devrais créer la page Lioneltest01 dans ta page utilisateur. Copier le code de la page principale de wikibook. :Puis faire les changements que tu juges nécessaires. Tu peux éventuellement faire plusieurs pages. Ensuite, tu mets cette page dans le bistro pour que l'on puisse voir le résultat. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 29 août 2026 à 21:22 (CEST) ::Je devrais, tu devrais, il devrait, nous devrions ... =) [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 29 août 2026 à 21:28 (CEST) :::Chaque chose en son temps. :::1. Débattre ici des idées de changements. :::2. Mettre les choses en place pour leur réalisation, comme la mise à jour du modèle:boite déroulante en s'inspirant de celui de Wikipédia ou d'autres sites. :::3. Quand il n'y a plus de nouvelle idée ici, créer une page genre :[[Wikilivres:Prise de décision/Nouvelle page d'accueil et gestion des abandons]] avec une description précise des projets de changements, et une éventuelle [[Accueil/projet|page de présentation du projet d'un nouvelle page d'accueil]] :::4. Poursuivre les discussions et propositions si nécessaire. :::5. Quand on a fait le tour, passer au vote. :::6. Mettre les décisions en application. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 29 août 2026 à 22:03 (CEST) ::::Je propose actuellement un projet qui se trouve sur ma page utilisateur. Pourrais-tu lancer une prise de décision sur ce projet. Je ne sais pas comment on fait une telle demande. Je suis prêt à présenter mon projet et à recevoir les critiques. Merci pour ton aide. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 29 août 2026 à 22:16 (CEST) :::::''Aide-toi le ciel t'aidera''. La bureaucratie, c'est pas mon truc. Et puis ça m'irrite un peu que tu enchaines des demandes de faire des choses qui ne demande pas les outils d'administrateurs. Raisons pour lequelles, je pense finalement que l'on gagnerait à appliquer la [[w:Do-ocratie|Do-ocratie]] qui a fait ses preuves dans le monde du libre dont les projets Wikimédia sont issus. :::::Si tu as une idée et qu'elle ne demande pas des droits de modification que tu n'as pas, fait la et on se retrouve en page de discussion de la page de tes changements en cas de problèmes et pour d'éventuelle commentaire. Sans réaction, pense à ''qui ne dit mot conscent''. :::::Si tes idées demandes les outils d'administrateurs, tu peux alors demander à un admin de le faire. Libre à lui de le faire ou pas et si ça lui plait, si il a envie, si il prend le temps. Nous sommes bénévoles... Il y a bien des gens payés par la fondation, mais comment dire... Disons qu'ils ne sont pas des plus serviables. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 29 août 2026 à 22:57 (CEST) ::::::Excuse moi je pensais qu'il faillait être administrateur pour faire une demande de prise de décision. Je vais donc essayer tous seul. Merci pour ton aide. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 29 août 2026 à 23:06 (CEST) :::::::Ok @[[Utilisateur:Xhungab|Xhungab]], pas de soucis. On ne peut pas tout savoir =) [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 30 août 2026 à 01:45 (CEST) == Wikilivres:Prise de décision/Créer un espace lecteurs et un espace auteurs sur la page d'accueil == J'ai posé une demande de prise de décision concernant le sujet suivant. '''[https://fr.wikibooks.org/wiki/Wikilivres:Prise_de_d%C3%A9cision/Cr%C3%A9er_un_espace_lecteurs_et_un_espace_auteurs_sur_la_page_d%27accueil Créer un espace lecteurs et un espace Les coulisses de Wikibook]''' Merci pour votre participation [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 30 août 2026 à 00:43 (CEST) :Bonjour @[[Utilisateur:Xhungab|Xhungab]]. Content de te revoir dans les murs de Wikiversité après nos échanges sur Wikilivres. Je comprends ton sentiment de solitude, je le ressens aussi souvent. Il faut l'accepter et ne pas se décourager pour autant. Nous avons la chance de fonctionner dans une organisation bénévole, dans laquelle personne n'est tenu de répondre à des demandes. De ce fait, les choses évoluent lentement et en fonction des disponibilités. Dis-toi aussi que la période estivale est toujours un moment d'acalmie dans la participation. Moi par exemple, quand il fait bon, je préfère passer du temps sur des occupations extérieures que devant un écran. :Cela dit, tu dois garder 3 règles implicites qui sont fréquemment appliquées dans les projets Wikimédia. :1. ''Qui ne dit mot consent''. Si les personnes ne réagissent pas, cela ne veut pas dire qu'elles ne lisent pas. Par économie de temps et pour les personnes trop occupées ou pas assez motivées pour s'investir, ne pas réagir est un signe de consentement implicite. :2. ''Les absents ont toujours tort.'' Lorsque l'on fait les choses dans les règles de l'art, par exemple comme tu le fais en lançant une prise de décision pour opérer un changement significatif dans Wikilivres, aucune personne absente durant un processus de décision clôturé viendra remettre en cause les choses par la suite. :3. La doocratie. Les wikis sont des espaces de libertés au niveau de l'amélioration des contenus, mais également de l'organisation et des formes de contenus. En dehors du vandalisme qui est bien contrôlé par des personnes telles que @[[Utilisateur:Crochet.david|Crochet.david]], toute initiative est souvent perçue comme bienvenue. @[[Utilisateur:Fourmidable|Fourmidable]], par exemple, depuis qu'il est administrateur, a fait de nombreuses améliorations dans Wikiversité sans demander l'avis de la communauté. Personne ne lui a reproché son investissement. Si quelque chose qu'il a entrepris ne plaît pas à quelqu'un, une discussion commence entre lui et cette personne pour chercher une entente. :Voilà tout. :Je te rejoins aussi sur le fait que ce qui est décidé et mis en place sur Wikilivres peut tout à fait servir d'exemple pour Wikiversité et vice versa. Je ne suis pas le seul à être administrateur dans les deux projets et cela aide beaucoup à la création d'une synergie entre les deux projets qui sont finalement très complémentaires. Cela n'est pas étonnant d'ailleurs lorsque l'on sait que Wikiversité est né de Wikilivres. :Une belle fin de journée à toi et à tous ceux qui liront ce message. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 6 septembre 2026 à 13:30 (CEST) ::Merci. Pour tes explications. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 6 septembre 2026 à 13:50 (CEST) :::Avec plaisir. Pense aussi qu'il n'y a pas de combat dans les projets Wiki, mais un lent processus évolutif, sans fin programmée, et qui est pour moi l'un des meilleurs que je connaisse. [[User:Lionel Scheepmans|Lionel Scheepmans]] <sup><big>✉</big> [[User talk:Lionel Scheepmans|Contact]]</sup> <sub>Désolé pour ma [[w:dysorthographie|dysorthographie]], [[w:dyslexie|dyslexie]] et [[wikt:distraction|"dys"traction]].</sub> 6 septembre 2026 à 13:57 (CEST) == Les RAW sont arrivés, la rentrée peut commencer ! == <div style="background-color:#177860; border-radius:.2em; color:#FFF;padding: 1em;"> <div style="font-family: Century Gothic; font-size:3em; line-height:120%;text-align:center;color: #fff;">RAW</div> <div style="margin-bottom:1.5em; margin-top:1em; text-align:center; color: #fff;">Le numéro de&nbsp;septembre 2026 ({{numéro|296}}) est sorti.</div> <div class="center">[[:w:Wikipédia:RAW/2026-09-05|<span class="cdx-button cdx-button--fake-button cdx-button--fake-button--enabled" style="white-space:break-spaces; text-align:center; gap:0;"role="button" aria-disabled="false">Lire l'infolettre</span>]]</div> </div> '''C'est la rentrée''' Après un numéro très dense en août en raison de la Wikimania, celui-ci est un peu plus léger car l'équipe s'est octroyée quelques vacances. Cela n'empêche pas le mouvement de continuer à vivre, avec une rentrée déjà animée ! Du côté des projets francophones, le Wiktionnaire vient de franchir le cap symbolique des sept millions d’entrées, tandis que Wikipédia continue de voir fleurir sondages et discussions sur son fonctionnement. Une rentrée, donc, placée sous le signe de la construction collective : de nouveaux outils pour contribuer, de nouveaux espaces pour échanger, de nouvelles formes d’organisation… Ce numéro des RAW vous propose de faire le tour de cette actualité, avant de reprendre tranquillement le chemin des projets Wikimédia. L'équipe de la rédaction a également commencé à réfléchir à un nouveau nom pour remplacer l'énigmatique « RAW ». N'hésitez pas à venir donner votre avis dans la [[:w:Discussion Wikipédia:RAW/Rédaction#Changement de nom|section dédiée]] ! ---- Au menu de ce numéro : '''<span style="margin:0;background:var(--color-success, #177860);padding:3px;color:white">En bref</span>''' — Des actualités francophones et du mouvement Wikimédia, des nouveaux outils et des articles de recherche '''<span style="margin:0;background:var(--color-success, #177860);padding:3px;color:white">Le wik'hit parade</span>''' — Une analyse des articles les plus vus le mois dernier [[Fichier:Toicon-icon-sharp-corners-draw.svg|20px|classe=skin-invert-image]] Les RAW, c'est aussi vous : n'hésitez pas à suggérer des sujets ou participer à la rédaction du [[:w:Wikipédia:RAW/Rédaction|prochain numéro]] ! [[Utilisateur:Antimuonium|Antimuonium]] ([[Discussion utilisateur:Antimuonium|discussion]]) 5 septembre 2026 à 00:17 (CEST) == Wikilivres:Prise de décision/Modifier le contenue de l'espace Nouveautés == J'ai posé une demande de prise de décision concernant le sujet suivant. [https://fr.wikibooks.org/wiki/Wikilivres:Prise_de_d%C3%A9cision/Modifier_le_contenue_de_l%27espace_Nouveaut%C3%A9s Wikilivres:Prise de décision/Modifier le contenue de l'espace Nouveautés] Merci pour votre participation [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 9 septembre 2026 à 14:38 (CEST) == '''Décision''':Prise de décision/Créer un espace lecteurs et un espace auteurs sur la page d'accueil == J'ai posé une demande de prise de décision concernant le sujet suivant. '''[https://fr.wikibooks.org/wiki/Wikilivres:Prise_de_d%C3%A9cision/Cr%C3%A9er_un_espace_lecteurs_et_un_espace_auteurs_sur_la_page_d%27accueil Créer un espace lecteurs et un espace Les coulisses de Wikibook]''' '''Décision pour : [https://fr.wikibooks.org/wiki/Utilisateur:Xhungab '''Wikibook''']''' Résultats : 3 + Pour, un Neutre Neutre et un - Contre soit 60 % de + Pour par rapport au total des votes exprimés : la proposition est adoptée. Quelques propositions de reformulations et de nuances ont été faites qui pourront être prise en compte. Cdlt, VIGNERON * discut. 13 septembre 2026 à 18:27 (CEST) Merci pour votre participation. Ma première proposition était un peu malhabile, mais grâce à vos interventions je pense que l'on a créé quelque chose de correct. '''Merci à tous''' [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 13 septembre 2026 à 20:22 (CEST) == Je ne suis donc pas content tous seul. (2) == Discutons dans la page d'accueil. '''[https://fr.wikibooks.org/wiki/Discussion:Accueil#Je_ne_suis_donc_pas_content_tous_seul._%282%29 Je ne suis pas content tous seul]''' Version 2 dans la page d'accueil. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 17 septembre 2026 à 14:28 (CEST) == Des RAW tout frais, à lire sans délai ! == <div style="background-color:#177860; border-radius:.2em; color:#FFF;padding: 1em;"> <div style="font-family: Century Gothic; font-size:3em; line-height:120%;text-align:center;color: #fff;">RAW</div> <div style="margin-bottom:1.5em; margin-top:1em; text-align:center; color: #fff;">Le numéro d'octobre 2026 ({{numéro|297}}) est sorti.</div> <div class="center">[[:w:Wikipédia:RAW/2026-10-05|<span class="cdx-button cdx-button--fake-button cdx-button--fake-button--enabled" style="white-space:break-spaces; text-align:center; gap:0;"role="button" aria-disabled="false">Lire l'infolettre</span>]]</div> </div> '''Contribuer, chacun à sa manière''' Dans une grande partie de la francophonie, l'automne pointe le bout de son nez, et avec lui un nouveau numéro des RAW ! Ce numéro fait la part belle à la contribution sous plusieurs formes : des campagnes de contribution et des ateliers pour améliorer des milliers d'articles sur divers sujets, des discussions communautaires, des élections, des événements, des nouveautés techniques, et même une nouvelle publication francophone ! La contribution peut aussi prendre des formes plus inattendues : pourquoi pas à travers des jeux ? Et pour découvrir plus concrètement ce que signifie contribuer à Wikipédia, nous nous intéresserons au parcours d'un utilisateur fraîchement élu administrateur : Nanoyo88. Par ailleurs, la réflexion sur le changement de titre des RAW avance : les deux propositions favorites sont ''La Wikigazette'' et ''La Gazette de Wikimédia''. N'hésitez pas à [[w:Discussion Wikipédia:RAW/Rédaction#Deuxième tour|donner votre avis]]. Bonne lecture, et surtout, bonnes contributions ! ---- Au menu de ce numéro : '''<span style="margin:0;background:var(--color-success, #177860);padding:3px;color:white">En bref</span>''' — Des actualités francophones et du mouvement Wikimédia, des nouveaux outils et des articles de recherche '''<span style="margin:0;background:var(--color-success, #177860);padding:3px;color:white">L'Interview</span>''' — Nanoyo88 : de débutant à administrateur, pour une Wikipédia plus accessible '''<span style="margin:0;background:var(--color-success, #177860);padding:3px;color:white">Le wik'hit parade</span>''' — Une analyse des articles les plus vus le mois dernier [[Fichier:Toicon-icon-sharp-corners-draw.svg|20px|classe=skin-invert-image]] Les RAW, c'est aussi vous : n'hésitez pas à suggérer des sujets ou participer à la rédaction du [[w:Wikipédia:RAW/Rédaction|prochain numéro]] ! [[Utilisateur:Antimuonium|Antimuonium]] ([[Discussion utilisateur:Antimuonium|discussion]]) 5 octobre 2026 à 00:06 (CEST) 8eta1vsineziv277md7amvhwf9c985f Wikilivres:Prise de décision/Créer un espace lecteurs et un espace auteurs sur la page d'accueil 4 84486 774108 773438 2026-10-04T12:18:03Z Fourmidable 92370 /* Votes */ 774108 wikitext text/x-wiki {{Prise de décision}} ==Présentation== '''Le problème :''' Actuellement, si un lecteur essaie de parcourir la bibliothèque à partir des listes : Arts, Loisirs, Histoire, Langues... Il se retrouve dans un labyrinte qui fini souvent sur une ébauche abandonné depuis plus de 5, 10, 15, 20 ans. '''La solution :''' Cette page [https://fr.wikibooks.org/wiki/Utilisateur:Xhungab '''Wikibook'''] propose la vitrine, les livres disponibles, les mini-livres disponibles pour les lecteurs. Les lecteurs ne seront en contact qu'avec les livres terminés. . En bas à côté des nouveautés, il y a '''[https://fr.wikibooks.org/wiki/Discussion_utilisateur:Xhungab Les coulisses de Wikibook]''', que l'on pourrait appeler, '''le local technique'''... avec tous les livres du site rangés par thème ou par niveau d'avancement si on clique sur l'image, ou bien des listes où les livres sont rangés par catégories. . '''Cette proposition sépare donc deux espaces. Un pour les étudiants qui cherchent à travailler sur un des livres de la bibliothèque et un pour les personnes curieuses qui veulent connaître les coulisses de Wikibook .''' . Ces changements ne demandent aucune modification technique. . Juste la création d'une page '''[https://fr.wikibooks.org/wiki/Discussion_utilisateur:Xhungab Les coulisses de Wikibook]'''. Il suffit de copier le tableau présent dans la page vitrine actuelle, et d'ajouter un petit texte de bienvenue. (10 minutes) . Modification de la page vitrine actuelle. . Insérer le lien de la page ci-dessus devant le lien "Nouveautés". en bas de la page. . Supprimer tous les liens : Arts, Loisirs, Histoire, et les remplacer par : * [[:Catégorie:Livres terminés|Livres disponibles]] * [[:Catégorie:Minilivres|Mini Livres disponibles]] (10 minutes) . En moins de 20 minutes, on a créé un espace convivial pour les lecteurs où les livres, tous terminés, sont rangés par ordre alphabétique, et un Espace technique, avec tous les livres du site rangés par thème ou par niveau d'avancement ou par catégories . Si cette proposition s'avérait mauvaise, il suffirait de remettre la version originale et de supprimer la page espace pour les auteurs. (2 minutes) Merci de m'avoir suivie jusqu'ici.[[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 30 août 2026 à 00:38 (CEST) == Discussions == [[Wikilivres:Le Bistro/2026|Voir les discussions antécédentes dans le bisto]] Suite à ce message : '''Les autres wikis utilisent déjà les sous-pages de l'auteur pour les brouillons, donc si les nouveaux ne respectent pas ça, ils posteront encore moins dans un nouvel espace.''' Dans un premier temps j'ai utilisé '''le titre un espace lecteurs et un espace auteurs'''. J'ai choisi le titre l'espace auteurs pour le mettre en opposition à l'espace lecteurs pour indiquer aux étudiants que si leur but était d'étudier leurs cours, l'espace pour les auteurs n'était pas approprié. J'ai donc remplacé '''Un espace auteurs''' par '''Les coulisses de Wikibook''' car cela a provoqué un malentendu. Les auteurs n'ont aucune obligation d'utiliser quoi que ce soit.Cet espace donne juste accès à tout le contenu de Wikibook comme le fait la version originale de la page de Wikibook. [[Utilisateur:Xhungab|Xhungab]] ---- ---- Je comprends bien l'idée de mieux distinguer dans la présentation les nouveautés finies des nouveautés en cours. Il me semble plus pertinent de distinguer cela en mettant en valeur sur une même page d'abord les livres finis tout en gardant sur la même page les livres en cours d'élaboration qui peuvent être tout aussi intéressant. Il ne me semble pas forcément pertinent d'après ce que j'ai compris, de créer un autre espace plus lointain et plus difficile d'accès pour les créations en cours. Si les coulisses de wiki livre crée un espace plus lointain et plus difficile d'accès pour ses livres en cours de création alors je suis contre la proposition. Il me semble d'ailleurs que le nombre de livres ne soit pas si grand et ne justifie pas une séparation aussi nette. Un livre en cours d'élaboration peut être tout aussi intéressant qu'un livre fini. [[Utilisateur:Sicarov|Sicarov]] Avez vous vu la deuxième proposition? [https://fr.wikibooks.org/wiki/Wikilivres:Prise_de_d%C3%A9cision/Modifier_le_contenue_de_l%27espace_Nouveaut%C3%A9s Wikilivres:Prise de décision/Modifier le contenue de l'espace Nouveautés] Les nouveaux livres et les livres en construction active seront mis en avant dans l'espace nouveauté.[[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 11 septembre 2026 à 21:33 (CEST) :Bonjour, j'ai pu regarder l'espace des nouveautés. Je n'ai pas peut-être pas bien tout compris. :Mais une chose dont je suis sûr c'est que plus l'information est loin avec un nombre de clics nécessaires, moins elle est accessible. J'aime beaucoup utiliser les dynamics qui sont présents sur les catégories existantes pour voir l'activité des nouveaux livres entre guillemets. :Certaines '''Dynamic list''' bien trouvées pourrait avoir leur place '''dans la page d'accueil de wiki livre''' pour montrer un échantillon de ce qui est en train de se faire. :J'ai également fait sur wiki source fr un switch avec plusieurs auteurs qui tournent. Un switch de livre pourrait également être proposé sur la page d'accueil avec une série de livres exemplaire et pertinent. [[Utilisateur:Sicarov|Sicarov]] ([[Discussion utilisateur:Sicarov|discussion]]) 11 septembre 2026 à 23:11 (CEST) ::Les noms des nouveaux livres et des livres en constructions active apparaîtrons en dessous des trois livres de la vitrine. Il suffira de ::cliquer sur le nom du livre en construction pour tomber sur sa page. Vous pouvez essayer sur l'exemple proposer. '''Pourriez vous cliquer sur l'image des livres dans la vitrine, pour voir si cela correspond à votre proposition.''' ::* '''Nouveautés et Projets En Construction Actif :''' [[Chanter les psaumes]] ::* '''Cette espace peut contenir une vingtaine de livres''' ::* '''[https://fr.wikibooks.org/wiki/Utilisateur:Xhungab '''Wikibook''']''' ::* [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 11 septembre 2026 à 23:19 (CEST) == Votes == Votez {{m|Pour}} / {{m|Contre}} / {{m|Neutre}}, avec vos arguments. # {{Pour}} Très bonne proposition. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 30 août 2026 à 00:47 (CEST) # {{Pour}} Et je propose alors de créer deux nouvelles pages qui affichent le contenu des différentes catégories de manières esthétique et contextualisée, grâce à l'[[mw:Extension:CategoryTree]]. Une première page pour les livres terminés ([[Utilisateur:Lionel Scheepmans/page avec liste des livres terminés|exemple]]) et une seconde pour les livres inachevés. {{non signé|Lionel Scheepmans}} # {{neutre}} Les autres wikis utilisent déjà les sous-pages de l'auteur pour les brouillons, donc si les nouveaux ne respectent pas ça, ils posteront encore moins dans un nouvel espace. [[Utilisateur:JackPotte|JackPotte]] ([[Discussion utilisateur:JackPotte|<span style="color:#FF6600">$</span>♠]]) 30 août 2026 à 11:31 (CEST) # {{contre}} les livres en cours de création sont tout aussi intéressants que des livres finis. Je veux éviter une plus grande invisibilisation des livres en cours de création. La proposition actuelle et peut-être plus nuancée. [[Utilisateur:Sicarov|Sicarov]] ([[Discussion utilisateur:Sicarov|discussion]]) 11 septembre 2026 à 19:56 (CEST) # {{Pour}} Le principe est bon, clairement séparer les livres terminés de ceux en recherche de contributeurs me plait, avoir un fourre-tout accesible sur la page d'accueil n'est pas l'idéal. J'aurais juste une remarque : le nom "Les coulisses de Wikibook" n'est pas très parlant. J'aurais plus utilisé "Wikilivres en cours de rédaction" ou "Wikilivres en recherche de contributeurs".[[Utilisateur:Mewtow|Mewtow]] ([[Discussion utilisateur:Mewtow|discussion]]) 11 septembre 2026 à 22:46 (CEST) #:Les coulisses de Wikibook était juste un nom pour la présentation de la nouvelle page. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 11 septembre 2026 à 23:10 (CEST) # {{Pour}} (je m'ajoute à la prise de décision après la fin de la période de "vote"). --[[Utilisateur:Fourmidable|Fourmidable]] ([[Discussion utilisateur:Fourmidable|discussion]]) 28 septembre 2026 à 13:07 (CEST) == Décision == * Résultats : 3 {{pour}}, un {{neutre}} et un {{contre}} soit 60 % de {{pour}} par rapport au total des votes exprimés : ''la proposition est adoptée''. Quelques propositions de reformulations et de nuances ont été faites qui pourront être prise en compte. Cdlt, [[Utilisateur:VIGNERON|V<span style="font-size:75%">IGNERON</span>]] * [[Discussion Utilisateur:VIGNERON|<sup>discut.</sup>]] 13 septembre 2026 à 18:27 (CEST) 6md62f9xkq53isy26shr0q48knswbpv 774109 774108 2026-10-04T12:18:18Z Fourmidable 92370 /* Décision */ 774109 wikitext text/x-wiki {{Prise de décision}} ==Présentation== '''Le problème :''' Actuellement, si un lecteur essaie de parcourir la bibliothèque à partir des listes : Arts, Loisirs, Histoire, Langues... Il se retrouve dans un labyrinte qui fini souvent sur une ébauche abandonné depuis plus de 5, 10, 15, 20 ans. '''La solution :''' Cette page [https://fr.wikibooks.org/wiki/Utilisateur:Xhungab '''Wikibook'''] propose la vitrine, les livres disponibles, les mini-livres disponibles pour les lecteurs. Les lecteurs ne seront en contact qu'avec les livres terminés. . En bas à côté des nouveautés, il y a '''[https://fr.wikibooks.org/wiki/Discussion_utilisateur:Xhungab Les coulisses de Wikibook]''', que l'on pourrait appeler, '''le local technique'''... avec tous les livres du site rangés par thème ou par niveau d'avancement si on clique sur l'image, ou bien des listes où les livres sont rangés par catégories. . '''Cette proposition sépare donc deux espaces. Un pour les étudiants qui cherchent à travailler sur un des livres de la bibliothèque et un pour les personnes curieuses qui veulent connaître les coulisses de Wikibook .''' . Ces changements ne demandent aucune modification technique. . Juste la création d'une page '''[https://fr.wikibooks.org/wiki/Discussion_utilisateur:Xhungab Les coulisses de Wikibook]'''. Il suffit de copier le tableau présent dans la page vitrine actuelle, et d'ajouter un petit texte de bienvenue. (10 minutes) . Modification de la page vitrine actuelle. . Insérer le lien de la page ci-dessus devant le lien "Nouveautés". en bas de la page. . Supprimer tous les liens : Arts, Loisirs, Histoire, et les remplacer par : * [[:Catégorie:Livres terminés|Livres disponibles]] * [[:Catégorie:Minilivres|Mini Livres disponibles]] (10 minutes) . En moins de 20 minutes, on a créé un espace convivial pour les lecteurs où les livres, tous terminés, sont rangés par ordre alphabétique, et un Espace technique, avec tous les livres du site rangés par thème ou par niveau d'avancement ou par catégories . Si cette proposition s'avérait mauvaise, il suffirait de remettre la version originale et de supprimer la page espace pour les auteurs. (2 minutes) Merci de m'avoir suivie jusqu'ici.[[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 30 août 2026 à 00:38 (CEST) == Discussions == [[Wikilivres:Le Bistro/2026|Voir les discussions antécédentes dans le bisto]] Suite à ce message : '''Les autres wikis utilisent déjà les sous-pages de l'auteur pour les brouillons, donc si les nouveaux ne respectent pas ça, ils posteront encore moins dans un nouvel espace.''' Dans un premier temps j'ai utilisé '''le titre un espace lecteurs et un espace auteurs'''. J'ai choisi le titre l'espace auteurs pour le mettre en opposition à l'espace lecteurs pour indiquer aux étudiants que si leur but était d'étudier leurs cours, l'espace pour les auteurs n'était pas approprié. J'ai donc remplacé '''Un espace auteurs''' par '''Les coulisses de Wikibook''' car cela a provoqué un malentendu. Les auteurs n'ont aucune obligation d'utiliser quoi que ce soit.Cet espace donne juste accès à tout le contenu de Wikibook comme le fait la version originale de la page de Wikibook. [[Utilisateur:Xhungab|Xhungab]] ---- ---- Je comprends bien l'idée de mieux distinguer dans la présentation les nouveautés finies des nouveautés en cours. Il me semble plus pertinent de distinguer cela en mettant en valeur sur une même page d'abord les livres finis tout en gardant sur la même page les livres en cours d'élaboration qui peuvent être tout aussi intéressant. Il ne me semble pas forcément pertinent d'après ce que j'ai compris, de créer un autre espace plus lointain et plus difficile d'accès pour les créations en cours. Si les coulisses de wiki livre crée un espace plus lointain et plus difficile d'accès pour ses livres en cours de création alors je suis contre la proposition. Il me semble d'ailleurs que le nombre de livres ne soit pas si grand et ne justifie pas une séparation aussi nette. Un livre en cours d'élaboration peut être tout aussi intéressant qu'un livre fini. [[Utilisateur:Sicarov|Sicarov]] Avez vous vu la deuxième proposition? [https://fr.wikibooks.org/wiki/Wikilivres:Prise_de_d%C3%A9cision/Modifier_le_contenue_de_l%27espace_Nouveaut%C3%A9s Wikilivres:Prise de décision/Modifier le contenue de l'espace Nouveautés] Les nouveaux livres et les livres en construction active seront mis en avant dans l'espace nouveauté.[[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 11 septembre 2026 à 21:33 (CEST) :Bonjour, j'ai pu regarder l'espace des nouveautés. Je n'ai pas peut-être pas bien tout compris. :Mais une chose dont je suis sûr c'est que plus l'information est loin avec un nombre de clics nécessaires, moins elle est accessible. J'aime beaucoup utiliser les dynamics qui sont présents sur les catégories existantes pour voir l'activité des nouveaux livres entre guillemets. :Certaines '''Dynamic list''' bien trouvées pourrait avoir leur place '''dans la page d'accueil de wiki livre''' pour montrer un échantillon de ce qui est en train de se faire. :J'ai également fait sur wiki source fr un switch avec plusieurs auteurs qui tournent. Un switch de livre pourrait également être proposé sur la page d'accueil avec une série de livres exemplaire et pertinent. [[Utilisateur:Sicarov|Sicarov]] ([[Discussion utilisateur:Sicarov|discussion]]) 11 septembre 2026 à 23:11 (CEST) ::Les noms des nouveaux livres et des livres en constructions active apparaîtrons en dessous des trois livres de la vitrine. Il suffira de ::cliquer sur le nom du livre en construction pour tomber sur sa page. Vous pouvez essayer sur l'exemple proposer. '''Pourriez vous cliquer sur l'image des livres dans la vitrine, pour voir si cela correspond à votre proposition.''' ::* '''Nouveautés et Projets En Construction Actif :''' [[Chanter les psaumes]] ::* '''Cette espace peut contenir une vingtaine de livres''' ::* '''[https://fr.wikibooks.org/wiki/Utilisateur:Xhungab '''Wikibook''']''' ::* [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 11 septembre 2026 à 23:19 (CEST) == Votes == Votez {{m|Pour}} / {{m|Contre}} / {{m|Neutre}}, avec vos arguments. # {{Pour}} Très bonne proposition. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 30 août 2026 à 00:47 (CEST) # {{Pour}} Et je propose alors de créer deux nouvelles pages qui affichent le contenu des différentes catégories de manières esthétique et contextualisée, grâce à l'[[mw:Extension:CategoryTree]]. Une première page pour les livres terminés ([[Utilisateur:Lionel Scheepmans/page avec liste des livres terminés|exemple]]) et une seconde pour les livres inachevés. {{non signé|Lionel Scheepmans}} # {{neutre}} Les autres wikis utilisent déjà les sous-pages de l'auteur pour les brouillons, donc si les nouveaux ne respectent pas ça, ils posteront encore moins dans un nouvel espace. [[Utilisateur:JackPotte|JackPotte]] ([[Discussion utilisateur:JackPotte|<span style="color:#FF6600">$</span>♠]]) 30 août 2026 à 11:31 (CEST) # {{contre}} les livres en cours de création sont tout aussi intéressants que des livres finis. Je veux éviter une plus grande invisibilisation des livres en cours de création. La proposition actuelle et peut-être plus nuancée. [[Utilisateur:Sicarov|Sicarov]] ([[Discussion utilisateur:Sicarov|discussion]]) 11 septembre 2026 à 19:56 (CEST) # {{Pour}} Le principe est bon, clairement séparer les livres terminés de ceux en recherche de contributeurs me plait, avoir un fourre-tout accesible sur la page d'accueil n'est pas l'idéal. J'aurais juste une remarque : le nom "Les coulisses de Wikibook" n'est pas très parlant. J'aurais plus utilisé "Wikilivres en cours de rédaction" ou "Wikilivres en recherche de contributeurs".[[Utilisateur:Mewtow|Mewtow]] ([[Discussion utilisateur:Mewtow|discussion]]) 11 septembre 2026 à 22:46 (CEST) #:Les coulisses de Wikibook était juste un nom pour la présentation de la nouvelle page. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 11 septembre 2026 à 23:10 (CEST) # {{Pour}} (je m'ajoute à la prise de décision après la fin de la période de "vote"). --[[Utilisateur:Fourmidable|Fourmidable]] ([[Discussion utilisateur:Fourmidable|discussion]]) 28 septembre 2026 à 13:07 (CEST) == Décision == * Résultats : <del>3</del> 4 {{pour}}, un {{neutre}} et un {{contre}} soit 60 % de {{pour}} par rapport au total des votes exprimés : ''la proposition est adoptée''. Quelques propositions de reformulations et de nuances ont été faites qui pourront être prise en compte. Cdlt, [[Utilisateur:VIGNERON|V<span style="font-size:75%">IGNERON</span>]] * [[Discussion Utilisateur:VIGNERON|<sup>discut.</sup>]] 13 septembre 2026 à 18:27 (CEST) lskhr58j91e3fj0212ddx07d7ysw44t 774110 774109 2026-10-04T12:29:32Z Fourmidable 92370 /* Décision */ 774110 wikitext text/x-wiki {{Prise de décision}} ==Présentation== '''Le problème :''' Actuellement, si un lecteur essaie de parcourir la bibliothèque à partir des listes : Arts, Loisirs, Histoire, Langues... Il se retrouve dans un labyrinte qui fini souvent sur une ébauche abandonné depuis plus de 5, 10, 15, 20 ans. '''La solution :''' Cette page [https://fr.wikibooks.org/wiki/Utilisateur:Xhungab '''Wikibook'''] propose la vitrine, les livres disponibles, les mini-livres disponibles pour les lecteurs. Les lecteurs ne seront en contact qu'avec les livres terminés. . En bas à côté des nouveautés, il y a '''[https://fr.wikibooks.org/wiki/Discussion_utilisateur:Xhungab Les coulisses de Wikibook]''', que l'on pourrait appeler, '''le local technique'''... avec tous les livres du site rangés par thème ou par niveau d'avancement si on clique sur l'image, ou bien des listes où les livres sont rangés par catégories. . '''Cette proposition sépare donc deux espaces. Un pour les étudiants qui cherchent à travailler sur un des livres de la bibliothèque et un pour les personnes curieuses qui veulent connaître les coulisses de Wikibook .''' . Ces changements ne demandent aucune modification technique. . Juste la création d'une page '''[https://fr.wikibooks.org/wiki/Discussion_utilisateur:Xhungab Les coulisses de Wikibook]'''. Il suffit de copier le tableau présent dans la page vitrine actuelle, et d'ajouter un petit texte de bienvenue. (10 minutes) . Modification de la page vitrine actuelle. . Insérer le lien de la page ci-dessus devant le lien "Nouveautés". en bas de la page. . Supprimer tous les liens : Arts, Loisirs, Histoire, et les remplacer par : * [[:Catégorie:Livres terminés|Livres disponibles]] * [[:Catégorie:Minilivres|Mini Livres disponibles]] (10 minutes) . En moins de 20 minutes, on a créé un espace convivial pour les lecteurs où les livres, tous terminés, sont rangés par ordre alphabétique, et un Espace technique, avec tous les livres du site rangés par thème ou par niveau d'avancement ou par catégories . Si cette proposition s'avérait mauvaise, il suffirait de remettre la version originale et de supprimer la page espace pour les auteurs. (2 minutes) Merci de m'avoir suivie jusqu'ici.[[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 30 août 2026 à 00:38 (CEST) == Discussions == [[Wikilivres:Le Bistro/2026|Voir les discussions antécédentes dans le bisto]] Suite à ce message : '''Les autres wikis utilisent déjà les sous-pages de l'auteur pour les brouillons, donc si les nouveaux ne respectent pas ça, ils posteront encore moins dans un nouvel espace.''' Dans un premier temps j'ai utilisé '''le titre un espace lecteurs et un espace auteurs'''. J'ai choisi le titre l'espace auteurs pour le mettre en opposition à l'espace lecteurs pour indiquer aux étudiants que si leur but était d'étudier leurs cours, l'espace pour les auteurs n'était pas approprié. J'ai donc remplacé '''Un espace auteurs''' par '''Les coulisses de Wikibook''' car cela a provoqué un malentendu. Les auteurs n'ont aucune obligation d'utiliser quoi que ce soit.Cet espace donne juste accès à tout le contenu de Wikibook comme le fait la version originale de la page de Wikibook. [[Utilisateur:Xhungab|Xhungab]] ---- ---- Je comprends bien l'idée de mieux distinguer dans la présentation les nouveautés finies des nouveautés en cours. Il me semble plus pertinent de distinguer cela en mettant en valeur sur une même page d'abord les livres finis tout en gardant sur la même page les livres en cours d'élaboration qui peuvent être tout aussi intéressant. Il ne me semble pas forcément pertinent d'après ce que j'ai compris, de créer un autre espace plus lointain et plus difficile d'accès pour les créations en cours. Si les coulisses de wiki livre crée un espace plus lointain et plus difficile d'accès pour ses livres en cours de création alors je suis contre la proposition. Il me semble d'ailleurs que le nombre de livres ne soit pas si grand et ne justifie pas une séparation aussi nette. Un livre en cours d'élaboration peut être tout aussi intéressant qu'un livre fini. [[Utilisateur:Sicarov|Sicarov]] Avez vous vu la deuxième proposition? [https://fr.wikibooks.org/wiki/Wikilivres:Prise_de_d%C3%A9cision/Modifier_le_contenue_de_l%27espace_Nouveaut%C3%A9s Wikilivres:Prise de décision/Modifier le contenue de l'espace Nouveautés] Les nouveaux livres et les livres en construction active seront mis en avant dans l'espace nouveauté.[[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 11 septembre 2026 à 21:33 (CEST) :Bonjour, j'ai pu regarder l'espace des nouveautés. Je n'ai pas peut-être pas bien tout compris. :Mais une chose dont je suis sûr c'est que plus l'information est loin avec un nombre de clics nécessaires, moins elle est accessible. J'aime beaucoup utiliser les dynamics qui sont présents sur les catégories existantes pour voir l'activité des nouveaux livres entre guillemets. :Certaines '''Dynamic list''' bien trouvées pourrait avoir leur place '''dans la page d'accueil de wiki livre''' pour montrer un échantillon de ce qui est en train de se faire. :J'ai également fait sur wiki source fr un switch avec plusieurs auteurs qui tournent. Un switch de livre pourrait également être proposé sur la page d'accueil avec une série de livres exemplaire et pertinent. [[Utilisateur:Sicarov|Sicarov]] ([[Discussion utilisateur:Sicarov|discussion]]) 11 septembre 2026 à 23:11 (CEST) ::Les noms des nouveaux livres et des livres en constructions active apparaîtrons en dessous des trois livres de la vitrine. Il suffira de ::cliquer sur le nom du livre en construction pour tomber sur sa page. Vous pouvez essayer sur l'exemple proposer. '''Pourriez vous cliquer sur l'image des livres dans la vitrine, pour voir si cela correspond à votre proposition.''' ::* '''Nouveautés et Projets En Construction Actif :''' [[Chanter les psaumes]] ::* '''Cette espace peut contenir une vingtaine de livres''' ::* '''[https://fr.wikibooks.org/wiki/Utilisateur:Xhungab '''Wikibook''']''' ::* [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 11 septembre 2026 à 23:19 (CEST) == Votes == Votez {{m|Pour}} / {{m|Contre}} / {{m|Neutre}}, avec vos arguments. # {{Pour}} Très bonne proposition. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 30 août 2026 à 00:47 (CEST) # {{Pour}} Et je propose alors de créer deux nouvelles pages qui affichent le contenu des différentes catégories de manières esthétique et contextualisée, grâce à l'[[mw:Extension:CategoryTree]]. Une première page pour les livres terminés ([[Utilisateur:Lionel Scheepmans/page avec liste des livres terminés|exemple]]) et une seconde pour les livres inachevés. {{non signé|Lionel Scheepmans}} # {{neutre}} Les autres wikis utilisent déjà les sous-pages de l'auteur pour les brouillons, donc si les nouveaux ne respectent pas ça, ils posteront encore moins dans un nouvel espace. [[Utilisateur:JackPotte|JackPotte]] ([[Discussion utilisateur:JackPotte|<span style="color:#FF6600">$</span>♠]]) 30 août 2026 à 11:31 (CEST) # {{contre}} les livres en cours de création sont tout aussi intéressants que des livres finis. Je veux éviter une plus grande invisibilisation des livres en cours de création. La proposition actuelle et peut-être plus nuancée. [[Utilisateur:Sicarov|Sicarov]] ([[Discussion utilisateur:Sicarov|discussion]]) 11 septembre 2026 à 19:56 (CEST) # {{Pour}} Le principe est bon, clairement séparer les livres terminés de ceux en recherche de contributeurs me plait, avoir un fourre-tout accesible sur la page d'accueil n'est pas l'idéal. J'aurais juste une remarque : le nom "Les coulisses de Wikibook" n'est pas très parlant. J'aurais plus utilisé "Wikilivres en cours de rédaction" ou "Wikilivres en recherche de contributeurs".[[Utilisateur:Mewtow|Mewtow]] ([[Discussion utilisateur:Mewtow|discussion]]) 11 septembre 2026 à 22:46 (CEST) #:Les coulisses de Wikibook était juste un nom pour la présentation de la nouvelle page. [[Utilisateur:Xhungab|Xhungab]] ([[Discussion utilisateur:Xhungab|discussion]]) 11 septembre 2026 à 23:10 (CEST) # {{Pour}} (je m'ajoute à la prise de décision après la fin de la période de "vote"). --[[Utilisateur:Fourmidable|Fourmidable]] ([[Discussion utilisateur:Fourmidable|discussion]]) 28 septembre 2026 à 13:07 (CEST) == Décision == * Résultats : <del>3</del> 4 {{pour}}, un {{neutre}} et un {{contre}} soit plus de 60 % de {{pour}} par rapport au total des votes exprimés : ''la proposition est adoptée''. Quelques propositions de reformulations et de nuances ont été faites qui pourront être prise en compte. Cdlt, [[Utilisateur:VIGNERON|V<span style="font-size:75%">IGNERON</span>]] * [[Discussion Utilisateur:VIGNERON|<sup>discut.</sup>]] 13 septembre 2026 à 18:27 (CEST) 7r8yfqkfzya5g68uftmg3wd0z8linxu Fonctionnement d'un ordinateur/Les schedulers larges et leurs optimisations 0 84538 774115 774095 2026-10-04T12:46:32Z Mewtow 31375 /* Les schedulers spécialisés pour les calculs d'adresse */ 774115 wikitext text/x-wiki Nous venons de parler de l'exécution dans le désordre pendant deux chapitres. Les chapitres précédents vous ont laissé penser qu'un processeur avec exécution dans le désordre était assez simple : on remplace le file de µops par un ''scheduler'', on ajoute éventuellement un unité de renommage de registres, et voilà ! Et en effet, l'implémentation la plus simple de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Mais dans la réalité, les processeurs modernes n'utilisent pas un ''scheduler'' unique. Les processeurs modernes ont besoin de ''scheduler'' très grands, avec un grand nombre d'entrées. Or, nous avons vu qu'un ''scheduler'' est un sorte de mémoire associative très complexe, avec un grand nombre de comparateurs. Et cela pose problème quand le ''scheduler'' doit mémoriser un grand nombre de µops. Je reste volontairement vague sur le nombre de µops exact, car il dépend de la finesse de gravure du processeur. Plus les transistors sont petits et efficaces, plus les ''schedulers'' peuvent grossir sans trop de problème. Mais malgré la loi de Moore, il arrive que des processeurs aient besoin de ''schedulers'' plus larges que ce que peut leur offrir la technologie de l'époque. Le cout en transistors d'un ''scheduler'' n'est pas proportionnel au nombre de µops qu'elle peut mémoriser. Par exemple, si je compare un ''scheduler'' de 32 entrées (32 µops max), elle n'utilise pas deux fois plus transistors qu'un ''scheduler'' de 16 entrées. Le cout en circuit n'est pas deux fois plus élevé, mais est approximativement 4 fois plus élevé. Le cout est presque quadratique, à savoir qu'il est proportionnel au carré du nombre d'entrées. Et il en est de même avec le performance. Pour un grand nombre de µops, un ''scheduler'' centralisé n'est pas très pratique. Il consomme beaucoup de transistors, chauffe beaucoup, n'est pas très rapide. La solution est de scinder un gros ''scheduler'' en plusieurs ''schedulers'' plus petits. Pour faire la différence avec un ''scheduler'' unique, nous allons parler de ''scheduler'' centralisé s'il n'y en a qu'un, de '''''scheduler'' décentralisé''' s'il y en a plusieurs. ==La spécialisation des ''schedulers'' décentralisés== La scission du ''scheduler'' doit idéalement se faire en tenant compte du reste du processeur. En effet, il faut relier les ''schedulers'' aux unités de calcul, ainsi qu'à l'unité mémoire, et cela ne peut pas se faire n'importe comment. Reste à voir comment faire. Précisons d'or et déjà que la scission dépend de si le processeur supporte l'exécution dans le désordre des accès mémoire. Si le processeur accepte de faire les accès mémoire dans le désordre, alors la scission sera différente. ===Les ''schedulers'' spécialisés pour les µops flottantes=== Prenons un processeur où les accès mémoire sont réalisés dans l'ordre. Le processeur a donc un simple ''scheduler'' pour les µops entières, les µops flottantes et les µops de branchements. La scission la plus basique sépare un ''scheduler'' pour les µops de calcul entières, d'un ''scheduler'' pour les µops flottantes. Il est aussi possible de séparer le ''scheduler'' pour les µops entières en deux : un pour les opérations de calcul et un autre pour les µops de branchements. La séparation est moins évidente, mais reste possible. Pour le moment, concentrons-nous sur la séparation du ''scheduler'' flottant et du ''scheduler entier''. L'avantage est que l'implémentation est très simple. Les flottants sont gérés à part des entiers. Ils ont leurs propres registres, des FPU séparées des ALU entières, bref : le séparation est complète. Utiliser un ''scheduler'' séparée va dans ce sens. Un avantage de cette scission se situe au niveau de la consommation d'électricité. Si le programme exécuté n'exécute aucune instruction flottante, le ''scheduler'' pour µops flottant peut être ''clock gate'', mis en veille, voire éteint. Le processeur consomme donc moins d'électricité, et chauffe moins. [[File:Processeur avec plusieurs fenêtres d'instruction.png|centre|vignette|upright=2.5|Processeur avec plusieurs ''schedulers''.]] Remarque importante : les deux ''schedulers'' travaillent indépendamment les unes des autres. Ils décident d'émettre une µops à chaque cycle, dans leur coin, sans rendre de compte à l'autre. La conséquence est qu'il est possible d'émettre deux µops par cycle, si les circonstances l'autorisent. Le ''scheduler'' entier émet une µops entière en même temps que le ''scheduler'' flottant émet une µops flottante. Les deux µops démarrent au même cycle, on n'émet pas l'une après l'autre. Le résultat est un gain en performance, sur lequel on reviendra plus tard. Un avantage de cette séparation est qu'elle permet de renommer les registres flottants et entiers séparément. Non pas que ce soit impossible avec un ''scheduler'' unique, mais c'est beaucoup plus compliqué et rarement fait. En séparant ''scheduler'' entier et ''scheduler'' flottant, les deux peuvent être précédés de deux unités de renommage séparées. Et à vrai dire, on peut même aller plus loin, en n'autorisant l'exécution dans le désordre que pour les opérations flottantes, ou au contraire que pour les opérations entières. La toute première implémentation de l'exécution dans le désordre, sur l'IBM 360, en est un bon exemple. La FPU de ce processeur était précédée par un ''scheduler'', mais les ALu entières n'étaient précédés que par une file de µops basique, sans exécution dans le désordre. ===Les ''schedulers'' spécialisés pour les calculs d'adresse=== Une autre scission, aussi naturelle que la précédente, serait d'utiliser un ''scheduler'' spécialisé pour les µops mémoire, et un ''scheduler'' pour le reste des µops. Mais cela ne marcherait pas, pour une raison que vous ne deviez pas oublier : les µops mémoire doivent se faire dans l'ordre du programme, sauf à utiliser des techniques de désambiguïsation mémoire. Par contre, il est possible de séparer les calculs d'adresse des autres opérations entières. Dans ce qui suit, on port du principe que les µops mémoire sont scindées en deux : une µop de calcul d'adresse, et une µops mémoire. La première calcule l'adresse utilisée par la seconde, la seconde exécute l'accès mémoire dans le cache. La séparation permet d'exécuter les calculs d'adresse dans le désordre, mais les µops mémoire se font elles dans l'ordre. Le transfert de l'adresse calculée peut se faire de plusieurs manières. Pour simplifier, on va supposer qu'elle se fait à travers un ''registre d'adresse'' "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. L'avantage est que les calculs d'adresse sont fait plus tôt, qu'avec un ''scheduler'' centralisé. Par exemple, on ne se retrouve plus dans une situation où plusieurs µops entières passer devant une µop de calcul d'adresse. Les µops entières et de calcul d'adresse sont prêtes, mais les µops entières sont exécutées en premier, la µop de calcul d'adresse est mise en attente pendant quelques cycles. Et non seulement les calculs d'adresse sont émis au plus tôt, mais ils ont de plus des ports de lecture dédié sur le banc de registres. Et effectuer les calculs d'adresse rapidement est primordial, car cela permet de connaitre à l'avance les adresses, donc d’effectuer les lectures le plus tôt possible. ===Les stations de réservation=== Un cas extrême est celui où chaque ALU/FPU a sa propre ''scheduler''. Par exemple, il y a un ''scheduler'' pour l'ALU entière, un autre pour le multiplieur entier, un autre pour le ''barrel shifter'', un autre pour l'unité mémoire, un autre pour l'unité de branchements, un autre pour l'additionneur flottant, un autre pour le multiplier flottant, etc. Un exemple historique est celui de le FPU de l'IBM 360 modèle 90, qui avait un additionneur flottant, un multiplieur flottant, et une unité mémoire pour les lectures/écritures flottantes. Les trois unités avaient leur propre ''scheduler''. Un exemple aussi extrême n'est pas si rare. Les processeurs AMD utilisent beaucoup cette technique, pour les ALU entières, à quelques nuances près. A l'opposé, Intel préfère utiliser des ''schedulers'' centralisés sur les anciens processeurs, deux ou trois ''schedulers'' sur les processeurs récents (2020 et plus). Les processeurs AMD utilisaient précisément un ''scheduler'' par ALU entière, alors que les FPUs partagent un ''scheduler'' flottante unique. Mais nous verrons cela en détail dans le chapitre sur les processeurs superscalaires x86. Toujours est-il qu'un cas aussi unique porte un nom. Les ''schedulers'' reliés à une seule ALU (entière ou flottante) sont appelés des '''stations de réservation''', ''Reservation Stations'' en anglais. Le terme a été introduit dans l'article de Tomasulo, qui décrivait justement la FPU de l'IBM 360, mentionnée plus haut. Mais dans ce cours, le terme décrit des ''schedulers'' qui mémorisent les opérandes des instructions, pas seulement les noms de registres associés. [[File:Contournement avec des stations de réservation.png|centre|vignette|upright=2.5|Stations de réservation]] Toujours est-il que l'exécution dans le désordre avec des stations de réservations a un intérêt historique fort. Elle a été utilisée dans deux techniques d'exécution dans le désordre très ancienne : l'algorithme de Tomasulo et le ''Scoreboarding'' du processeur CDC 6600. Ces deux implémentations ont introduit la technique, qui a été raffinée par la suite. Leur défaut est qu'elles ne géraient pas les exceptions précises, ce qui fait qu'on verra ces deux techniques dans le chapitre suivant, qui leur est dédié. ==Les ''schedulers'' décentralisés versus centralisés== Le choix entre un ''scheduler'' centralisés et plusieurs petits ''schedulers'' décentralisés est un compromis entre économie de circuits et flexibilité. Un gros ''scheduler'' unique est plus flexible, ce qui peut entrainer des gains de performance, mais coute beaucoup en circuits et peut réduire la fréquence du processeur. ===Un ''scheduler'' centralisé est plus flexible, mais plus couteux=== Pour expliquer en quoi un gros ''scheduler'' unique est plus flexible, nous allons prendre un exemple. Comparons un ''scheduler'' de 64 entrées, et deux ''schedulers'' de 32 entrées chacun. Les deux ''schedulers'' sont spécialisé :l'un est spécialisé dans les µops entières, l'autre dans les µops flottantes. En tout, les deux ''schedulers'' peuvent mémoriser au maximum 32 µops entières et 32 µops flottantes. A l'opposé, le gros ''scheduler'' unique peut mémoriser 64 µops, peu importe leur nature. Si le programme exécuté n'utilise pas de nombres flottants, le différence devient flagrante. La solution à deux ''schedulers'' est limitée à 32 µops, alors que le ''scheduler'' centralisé peut mémoriser le double. Les performances seront donc meilleures avec le ''scheduler'' centralisé. Et au-delà de ça, le ''scheduler'' décentralisé est utilisé de manière optimale si la moitié des µops sont des µops entières, et l'autre moitié des µops flottantes. Alors que le ''scheduler'' centralisé se moque de la répartition entre µops entières et flottantes. Pour le dire autrement, avec un ''scheduler'' décentralisé, il arrive qu'un ''scheduler'' soit remplis, alors que les autres sont sous-utilisés, voire vides. Ce qui n'arrive pas avec un ''scheduler'' centralisé. Par contre, les ''schedulers'' décentralisés utilisent plusieurs ''schedulers'' de "petite taille", plus rapides, plus économes en circuits et économes en énergie. Le compromis n'est donc pas entre un ''scheduler'' à 64 entrées versus deux ''schedulers'' de 32 entrées. Le compromis en pratique est entre un ''scheduler'' de 64 entrées versus 3 ''schedulers'' de 32 entrées. Un exemple pratique est celui de le microarchitecture ''Skyleke'' d'Intel. Intel est passé d'un ''scheduler'' centralisé pre-''Skyleke'' à deux ''schedulers'' sur ''Skyleke'', mais le nombre total d'entrées était globalement le même entre les deux, même si l'évolution de le finesse de gravure entre les deux biaise un peu les résultats. ===L'émission avec un ''scheduler'' décentralisé : ''dispatch'' et ''schedule''=== Un point important est qu'avec des ''schedulers'' décentralisés, l'émission est découpée en deux étages. Le premier étage envoie une µops vers le ''scheduler'' adéquat, le seconde émet la µops quand ses opérandes sont prêts. La première étape fait rentrer une µops dans le ''scheduler'', la seconde l'en fait sortir. Le premier étage est l'étage de '''''dispatch''''', le second est l'étage de '''''scheduling'''''. Le ''dispatch'' analyse la µops et décide où l'envoyer, selon que c'est une µops mémoire, flottante, entière. Le ''scheduling'' gère le disponibilité des opérandes et des unités de calcul. La µop émise est ajoutée au tampon de réordonnancement, s'il existe, lors de l'étape de ''dispatch''. Cette séparation fait qu'il est possible de démarrer l'exécution de plusieurs µops simultanément : une µop par ''scheduler''. Le fait est que deux ''schedulers'' différents émettent leurs µops sans se synchroniser avec les autres. Deux ''schedulers'' peuvent donc émettre chacun une µops, ce qui fait deux µops émises lors du même cycle. Cela permet d'exécuter en même temps deux ou trois instructions 1-cycle, comme des additions, soustractions, décalages, etc. Il devient aussi possible d'émettre un µop entière en même temps qu'un µop flottante, si les µops entières ont un ''scheduler'' séparée de celle des µops flottantes. Notons que cela permet d'émettre deux µops par cycle au niveau de l'étape de ''scheduling'', pas à l'étape de ''dispatch''. Aussi, nous parlerons de '''''multi-schedule'''''. Par contre, il faut faire une précision importante : le processeur ne peut toujours qu'émettre une ''instruction'' par cycle. Le processeur charge et décode une instruction par cycle, pas plus. Pour faire une comparaison, rappelez-vous l'exécution dans le désordre simple. Elle permet d'exécuter plusieurs instructions par cycle d'horloge, si on regarde ce qui se passe au niveau des unités de calcul. Mais si on fait la moyenne sur le temps long, on ne peut pas dépasser un IPC (''instruction per cycle'') de 1. Le ''multi-scheduling'' est similaire. Il permet de gagner en performance, mais ne permet pas de dépasser un IPC de 1. Il augmente l'IPC quand il est bas, mais il ne faut que le faire remonter vers le seuil de 1. ==Les files de µops et l'exécution dans le désordre== Depuis au moins trois chapitres, je vous ai fait croire que le ''scheduler'' remplaçait la file de micro-opérations, la file de µops. Plus précisémment, que le ''scheduler'' était une file de µops améliorée. Mais dans les faits, il est parfaitement possible d'utiliser une file de µops en complément d'un ''scheduler'', voire de plusieurs. Il est même possible de faire de l'exécution dans le désordre en utilisant uniquement des files de µops. Il est temps de voir comment ce genre de ruse est possible ===L'exécution dans le désordre avec des files de µops=== Prenez un ''scheduler'' décentralisé. Une optimisation consiste à remplacer les ''schedulers'' par des files de µops. L'idée est qu'un fois décodée, les instructions sont accumulées dans des files de µops séparées. Il y a typiquement un file de micro-opération pour les opérations entières, un autre pour les opérations flottantes, un autre pour les accès mémoire. D'autres processeurs utilisent un file pour les accès mémoire et un autre pour les autres instructions, comme le faisait le Pentium 4 d'Intel. Les files de µops ont chacune leur propre ''scoreboard'' pour détecter les dépendances. Les ''scoreboard'' des différentes files de µops communiquent entre eux pour gérer le disponibilité des registres. Quand un registre est lu ou écrit par un instruction, toutes les files de µops sont prévenues et mettent à jour leurs ''scoreboards'' en même temps. Le résultat est un processeur qui implémente une forme limitée d'exécution dans le désordre. Par exemple, prenons un processeur avec trois files de µops : une en aval de l'unité mémoire, une autre en aval de la FPU, et une autre en aval de l'ALU entière. Le processeur exécute les µops mémoire dans l'ordre, les µops entières dans l'ordre, et idem pour les µops flottantes. Par contre, une µop entière peut passer avant une µop mémoire, et réciproquement. Idem avec les µops flottantes, qui peuvent passer avant ou après une µops entières, comparé à l'ordre du programme. L'exécution dans le désordre est donc partielle. L'intérêt par rapport à l'exécution dans l'ordre est que si une dépendance bloque un FIFO, les autres peuvent continuer d'émettre des instructions. Les files de micro-opération émettent leurs instructions de manière indépendante, elles ne savent pas si telle µop dans un autre file doit passer avant ou après la µop qu'elles émettent. Par exemple, le processeur Pentium 4 avait deux files de µops séparées : un pour les µops mémoire et une autre pour les autres µops. Si jamais une µops mémoire bloquait le pipeline, l'autre file de µops pouvait continuer à émettre des instructions indépendante de le lecture/écriture bloquante. Un problème est qu'il est facile de se retrouver avec toutes les files de µops bloquées. Quand l'une est bloquée, les autres tendent à se vider rapidement, particulièrement quand c'est le file pour les accès mémoire qui se bloque. Le résultat est que les gains performances sont intermédiaires entre l'exécution dans l'ordre et l'exécution dans le désordre complète. L'avantage est que les files de µops sont plus économes en termes de transistors. Les ''schedulers'' sont des circuits complexes et couteux en circuits, quand on les compare à une file de µops de même taille. Le remplacement fait donc faire des économies de circuit substantielles, qu'on peut utiliser pour agrandir la file de µops résultante. Il y a donc le choix entre trois petits ''schedulers'' et trois grandes files de µops. L'agrandissement des files de µops compense partiellement le gain lié au fait que l'exécution dans le désordre est incomplète. Pour résumer, il existe 4 grandes techniques d'exécution dans le désordre. Elles peuvent se classer sur deux critères : est-ce que les instructions sont émises dans l'ordre, et combien il y a de files de micro-opération. {|class="wikitable" |- ! ! File de µops normale ! ''scheduler'' |- ! un seule | ''Scoreboard'' | ''scheduler'' centralisée |- ! Plusieurs | Plusieurs FIFOs, plusieurs ''Scoreboard'' indépendants | ''scheduler'' décentralisé |} ===L'usage de files d'instruction en complément des ''schedulers''=== S'il est possible de remplacer des ''schedulers'' par des files de µops, il est possible d'utiliser les deux en même temps, de manière complémentaires. L'idée est de faire précéder le ''scheduler'' par une file de µops. Ainsi, si le ''scheduler'' est remplit, l'unité d'émission peut accumuler des µops dans la file de µops, sans bloquer le pipeline. La technique a été introduite par les processeurs Intel et AMD modernes, vers la moitié des années 2020. Ils ont introduits des '''''non-scheduling queue''''' sur la plupart de leurs microarchitectures. Une autre manière de voir la situation est qu'au lieu d'utiliser un gros ''scheduler'' capable d'accumuler un grand nombre de µops, on utilise un ''sheduler'' plus petit couplé à une file de µops. Utiliser un gros ''scheduler'' demanderait trop de transistors, pour un gain en performance qui ne se manifesterait que dans des situations extrêmes, où le pipeline est bloqué pendant très longtemps. Utiliser un ''scheduler'' de taille modéré, qu'on complémente avec une file de µops pour gérer ces cas extrêmes, est un meilleur compromis. Les ''non-scheduling queue'' peuvent s'utiliser avec des ''schedulers'' décentralisés. Prenons un exemple avec trois ''schedulers'', un pour les µops mémoire, un autre pour l'ALU entière, et un autre pour la FPU. Il est possible de faire précéder chaque ''scheduler'' par sa propre ''non-scheduling queue''.Mais dans les faits, ce n'est pas ce qui est fait de nos jours. Autant les ''schedulers'' de l('unité mémoire et de la FPU disposent de leur ''non-scheduling queue'', autant ce n'est pas le cas pour celui lié à l'ALU entière. La raison est que les opérations entières sont plus rapides que les opérations flottantes, et les accès mémoire. Une opération entière prend quelques cycles d'horloge, alors qu'une opération flottante atteint la dizaine de cycles et les accès mémoire la centaine. Les µops mémoire tendent donc à s'accumuler plus vite que les autres, et le ''scheduler'' de lunité mémoire se remplit très vite. Il en est de même pour le ''scheduler'' flottant, mais il se remplit moins vite et a moins de chance d'être débordé. En tout cas, le ''scheduler'' entier, s'il a une taille suffisante, ne sera jamais remplit avant les autres. Il a peu de chances d'être bloqué parce que plein, il sera bloqué car les ''non-scheduling queue'' déborderont et bloqueront le pipeline avant. ===Le découplage décodage-renommage=== Les processeurs modernes utilisent une file de µops en aval de l'unité de renommage. Elle n'a pas de lien direct avec l'exécution dans le désordre, elle ne remplace pas un ''scheduler'', ni ne le complète. A la place, elle sert en quelque sorte de lubrifiant entre le décodeur d'instruction et l'unité de renommage. Nous parlerons de '''découplage décodage-renommage''' dans ce qui suit. Voyons en quoi il consiste. Une instruction, une fois décodée, donne une ou plusieurs µops. L’unité de renommage, quant à elle, traite une µop à la fois (sauf sur les CPU superscalaires, qu'on verra dans le prochain chapitre). Il y a donc un ''mismatch'' entre sortie des décodeurs et entrée de l'unité de renommage. Pour régler ce problème, on insère la file de µops entre le décodeur et l'unité de renommage. Le décodeur peut accumuler plusieurs µops par cycle dans la file de µops, mais elles sortiront une par une. L'unité de renommage pourra donc aller à son rythme de une µop par cycle, pendant que l'unité de décodage produira autant de µops par cycle que nécessaire. La file de µops sert donc à découpler le décodage et l'unité de renommage, à savoir les faire fonctionner chacun à leur rythme. [[File:Découplage du renommage et du décodage.png|centre|vignette|upright=2|Découplage du renommage et du décodage.]] Un autre avantage de cette file de µops est qu'elle peut implémenter des optimisations qu'un ''scheduler'' ne peut pas faire. Par exemple, elle peut implémenter un ''Loop Stream Detector'', qui permet de ne pas charger/décoder les isntructions de petites boucles. Si une boucle tient complétement dans la file de µops, ses µops sont lues depuis la file de µops, et non depuis le cache. Un ''scheduler'' en est incapable. Le rôle de découplage décodage-renommage est distinct du fait d'accumuler des µops quand le ''scheduler'' est plein. En pratique, ils sont remplis par des files de µops séparées. Les processeurs modenres disposent de ''non-scheduling queue'' séparées de la file de µops proprement dite. Les ''non-scheduling queue'' sont places après l'unité de renommage de registre, alors que la file de µops est par nature avant l'unité de renommage. Les processeurs Intel, AMD, et potentiellement ARM, avaient une file de µops, mais pas de ''non-scheduling queue''. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le scoreboarding et l'algorithme de Tomasulo | nextText=Le scoreboarding et l'algorithme de Tomasulo }} </noinclude> 63c3gk7iyiui5zppdmw748cdhhuf047 774118 774115 2026-10-04T13:14:12Z Mewtow 31375 /* Les schedulers spécialisés pour les µops flottantes */ 774118 wikitext text/x-wiki Nous venons de parler de l'exécution dans le désordre pendant deux chapitres. Les chapitres précédents vous ont laissé penser qu'un processeur avec exécution dans le désordre était assez simple : on remplace le file de µops par un ''scheduler'', on ajoute éventuellement un unité de renommage de registres, et voilà ! Et en effet, l'implémentation la plus simple de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Mais dans la réalité, les processeurs modernes n'utilisent pas un ''scheduler'' unique. Les processeurs modernes ont besoin de ''scheduler'' très grands, avec un grand nombre d'entrées. Or, nous avons vu qu'un ''scheduler'' est un sorte de mémoire associative très complexe, avec un grand nombre de comparateurs. Et cela pose problème quand le ''scheduler'' doit mémoriser un grand nombre de µops. Je reste volontairement vague sur le nombre de µops exact, car il dépend de la finesse de gravure du processeur. Plus les transistors sont petits et efficaces, plus les ''schedulers'' peuvent grossir sans trop de problème. Mais malgré la loi de Moore, il arrive que des processeurs aient besoin de ''schedulers'' plus larges que ce que peut leur offrir la technologie de l'époque. Le cout en transistors d'un ''scheduler'' n'est pas proportionnel au nombre de µops qu'elle peut mémoriser. Par exemple, si je compare un ''scheduler'' de 32 entrées (32 µops max), elle n'utilise pas deux fois plus transistors qu'un ''scheduler'' de 16 entrées. Le cout en circuit n'est pas deux fois plus élevé, mais est approximativement 4 fois plus élevé. Le cout est presque quadratique, à savoir qu'il est proportionnel au carré du nombre d'entrées. Et il en est de même avec le performance. Pour un grand nombre de µops, un ''scheduler'' centralisé n'est pas très pratique. Il consomme beaucoup de transistors, chauffe beaucoup, n'est pas très rapide. La solution est de scinder un gros ''scheduler'' en plusieurs ''schedulers'' plus petits. Pour faire la différence avec un ''scheduler'' unique, nous allons parler de ''scheduler'' centralisé s'il n'y en a qu'un, de '''''scheduler'' décentralisé''' s'il y en a plusieurs. ==La spécialisation des ''schedulers'' décentralisés== La scission du ''scheduler'' doit idéalement se faire en tenant compte du reste du processeur. En effet, il faut relier les ''schedulers'' aux unités de calcul, ainsi qu'à l'unité mémoire, et cela ne peut pas se faire n'importe comment. Reste à voir comment faire. Précisons d'or et déjà que la scission dépend de si le processeur supporte l'exécution dans le désordre des accès mémoire. Si le processeur accepte de faire les accès mémoire dans le désordre, alors la scission sera différente. ===Les ''schedulers'' spécialisés pour les µops flottantes=== Prenons un processeur où les accès mémoire sont réalisés dans l'ordre. Le processeur a donc un simple ''scheduler'' pour les µops entières, les µops flottantes et les µops de branchements. La scission la plus basique sépare un ''scheduler'' pour les µops de calcul entières, d'un ''scheduler'' pour les µops flottantes. Il est aussi possible de séparer le ''scheduler'' pour les µops entières en deux : un pour les opérations de calcul et un autre pour les µops de branchements. La séparation est moins évidente, mais reste possible. Pour le moment, concentrons-nous sur la séparation du ''scheduler'' flottant et du ''scheduler entier''. [[File:Processeur avec des fenêtres d'instruction décentralisées.png|centre|vignette|upright=1.5|Processeur avec des fenêtres d'instruction décentralisées]] L'avantage est que l'implémentation est très simple. Les flottants sont gérés à part des entiers. Ils ont leurs propres registres, des FPU séparées des ALU entières, bref : le séparation est complète. Utiliser un ''scheduler'' séparée va dans ce sens. Un avantage de cette scission se situe au niveau de la consommation d'électricité. Si le programme exécuté n'exécute aucune instruction flottante, le ''scheduler'' pour µops flottant peut être ''clock gate'', mis en veille, voire éteint. Le processeur consomme donc moins d'électricité, et chauffe moins. Remarque importante : les deux ''schedulers'' travaillent indépendamment les unes des autres. Ils décident d'émettre une µops à chaque cycle, dans leur coin, sans rendre de compte à l'autre. La conséquence est qu'il est possible d'émettre deux µops par cycle, si les circonstances l'autorisent. Le ''scheduler'' entier émet une µops entière en même temps que le ''scheduler'' flottant émet une µops flottante. Les deux µops démarrent au même cycle, on n'émet pas l'une après l'autre. Le résultat est un gain en performance, sur lequel on reviendra plus tard. Un avantage de cette séparation est qu'elle permet de renommer les registres flottants et entiers séparément. Non pas que ce soit impossible avec un ''scheduler'' unique, mais c'est beaucoup plus compliqué et rarement fait. En séparant ''scheduler'' entier et ''scheduler'' flottant, les deux peuvent être précédés de deux unités de renommage séparées. Et à vrai dire, on peut même aller plus loin, en n'autorisant l'exécution dans le désordre que pour les opérations flottantes, ou au contraire que pour les opérations entières. La toute première implémentation de l'exécution dans le désordre, sur l'IBM 360, en est un bon exemple. La FPU de ce processeur était précédée par un ''scheduler'', mais les ALu entières n'étaient précédés que par une file de µops basique, sans exécution dans le désordre. L'implémentation peut utiliser la ''lecture après émission'' ou ''avant émission''. Dans les deux cas, les registres entiers et flottants sont lus en parallèle. Ainsi, si une µop entière et une µop flottante sont émises en même temps, les opérandes de la µop entière sont lues depuis le banc de registres généraux, les opérandes de la µop flottante sont lus depuis les registres flottants, les deux lectures se font en même temps. Par contre, cela a un impact sur le ''scheduler''. Avec la ''lecture avant émission'', il doit avoir assez de ports pour insérer 4 opérandes dans deux µops. [[File:Processeur avec plusieurs fenêtres d'instruction.png|centre|vignette|upright=2.5|Processeur avec un ''scheduler'' flottant, avec lecture après émission.]] [[File:Read Before Issue.png|centre|vignette|upright=2.5|Processeur avec un ''scheduler'' flottant, avec lecture avant émission.]] ===Les ''schedulers'' spécialisés pour les calculs d'adresse=== Une autre scission, aussi naturelle que la précédente, serait d'utiliser un ''scheduler'' spécialisé pour les µops mémoire, et un ''scheduler'' pour le reste des µops. Mais cela ne marcherait pas, pour une raison que vous ne deviez pas oublier : les µops mémoire doivent se faire dans l'ordre du programme, sauf à utiliser des techniques de désambiguïsation mémoire. Par contre, il est possible de séparer les calculs d'adresse des autres opérations entières. Dans ce qui suit, on port du principe que les µops mémoire sont scindées en deux : une µop de calcul d'adresse, et une µops mémoire. La première calcule l'adresse utilisée par la seconde, la seconde exécute l'accès mémoire dans le cache. La séparation permet d'exécuter les calculs d'adresse dans le désordre, mais les µops mémoire se font elles dans l'ordre. Le transfert de l'adresse calculée peut se faire de plusieurs manières. Pour simplifier, on va supposer qu'elle se fait à travers un ''registre d'adresse'' "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. L'avantage est que les calculs d'adresse sont fait plus tôt, qu'avec un ''scheduler'' centralisé. Par exemple, on ne se retrouve plus dans une situation où plusieurs µops entières passer devant une µop de calcul d'adresse. Les µops entières et de calcul d'adresse sont prêtes, mais les µops entières sont exécutées en premier, la µop de calcul d'adresse est mise en attente pendant quelques cycles. Et non seulement les calculs d'adresse sont émis au plus tôt, mais ils ont de plus des ports de lecture dédié sur le banc de registres. Et effectuer les calculs d'adresse rapidement est primordial, car cela permet de connaitre à l'avance les adresses, donc d’effectuer les lectures le plus tôt possible. ===Les stations de réservation=== Un cas extrême est celui où chaque ALU/FPU a sa propre ''scheduler''. Par exemple, il y a un ''scheduler'' pour l'ALU entière, un autre pour le multiplieur entier, un autre pour le ''barrel shifter'', un autre pour l'unité mémoire, un autre pour l'unité de branchements, un autre pour l'additionneur flottant, un autre pour le multiplier flottant, etc. Un exemple historique est celui de le FPU de l'IBM 360 modèle 90, qui avait un additionneur flottant, un multiplieur flottant, et une unité mémoire pour les lectures/écritures flottantes. Les trois unités avaient leur propre ''scheduler''. Un exemple aussi extrême n'est pas si rare. Les processeurs AMD utilisent beaucoup cette technique, pour les ALU entières, à quelques nuances près. A l'opposé, Intel préfère utiliser des ''schedulers'' centralisés sur les anciens processeurs, deux ou trois ''schedulers'' sur les processeurs récents (2020 et plus). Les processeurs AMD utilisaient précisément un ''scheduler'' par ALU entière, alors que les FPUs partagent un ''scheduler'' flottante unique. Mais nous verrons cela en détail dans le chapitre sur les processeurs superscalaires x86. Toujours est-il qu'un cas aussi unique porte un nom. Les ''schedulers'' reliés à une seule ALU (entière ou flottante) sont appelés des '''stations de réservation''', ''Reservation Stations'' en anglais. Le terme a été introduit dans l'article de Tomasulo, qui décrivait justement la FPU de l'IBM 360, mentionnée plus haut. Mais dans ce cours, le terme décrit des ''schedulers'' qui mémorisent les opérandes des instructions, pas seulement les noms de registres associés. [[File:Contournement avec des stations de réservation.png|centre|vignette|upright=2.5|Stations de réservation]] Toujours est-il que l'exécution dans le désordre avec des stations de réservations a un intérêt historique fort. Elle a été utilisée dans deux techniques d'exécution dans le désordre très ancienne : l'algorithme de Tomasulo et le ''Scoreboarding'' du processeur CDC 6600. Ces deux implémentations ont introduit la technique, qui a été raffinée par la suite. Leur défaut est qu'elles ne géraient pas les exceptions précises, ce qui fait qu'on verra ces deux techniques dans le chapitre suivant, qui leur est dédié. ==Les ''schedulers'' décentralisés versus centralisés== Le choix entre un ''scheduler'' centralisés et plusieurs petits ''schedulers'' décentralisés est un compromis entre économie de circuits et flexibilité. Un gros ''scheduler'' unique est plus flexible, ce qui peut entrainer des gains de performance, mais coute beaucoup en circuits et peut réduire la fréquence du processeur. ===Un ''scheduler'' centralisé est plus flexible, mais plus couteux=== Pour expliquer en quoi un gros ''scheduler'' unique est plus flexible, nous allons prendre un exemple. Comparons un ''scheduler'' de 64 entrées, et deux ''schedulers'' de 32 entrées chacun. Les deux ''schedulers'' sont spécialisé :l'un est spécialisé dans les µops entières, l'autre dans les µops flottantes. En tout, les deux ''schedulers'' peuvent mémoriser au maximum 32 µops entières et 32 µops flottantes. A l'opposé, le gros ''scheduler'' unique peut mémoriser 64 µops, peu importe leur nature. Si le programme exécuté n'utilise pas de nombres flottants, le différence devient flagrante. La solution à deux ''schedulers'' est limitée à 32 µops, alors que le ''scheduler'' centralisé peut mémoriser le double. Les performances seront donc meilleures avec le ''scheduler'' centralisé. Et au-delà de ça, le ''scheduler'' décentralisé est utilisé de manière optimale si la moitié des µops sont des µops entières, et l'autre moitié des µops flottantes. Alors que le ''scheduler'' centralisé se moque de la répartition entre µops entières et flottantes. Pour le dire autrement, avec un ''scheduler'' décentralisé, il arrive qu'un ''scheduler'' soit remplis, alors que les autres sont sous-utilisés, voire vides. Ce qui n'arrive pas avec un ''scheduler'' centralisé. Par contre, les ''schedulers'' décentralisés utilisent plusieurs ''schedulers'' de "petite taille", plus rapides, plus économes en circuits et économes en énergie. Le compromis n'est donc pas entre un ''scheduler'' à 64 entrées versus deux ''schedulers'' de 32 entrées. Le compromis en pratique est entre un ''scheduler'' de 64 entrées versus 3 ''schedulers'' de 32 entrées. Un exemple pratique est celui de le microarchitecture ''Skyleke'' d'Intel. Intel est passé d'un ''scheduler'' centralisé pre-''Skyleke'' à deux ''schedulers'' sur ''Skyleke'', mais le nombre total d'entrées était globalement le même entre les deux, même si l'évolution de le finesse de gravure entre les deux biaise un peu les résultats. ===L'émission avec un ''scheduler'' décentralisé : ''dispatch'' et ''schedule''=== Un point important est qu'avec des ''schedulers'' décentralisés, l'émission est découpée en deux étages. Le premier étage envoie une µops vers le ''scheduler'' adéquat, le seconde émet la µops quand ses opérandes sont prêts. La première étape fait rentrer une µops dans le ''scheduler'', la seconde l'en fait sortir. Le premier étage est l'étage de '''''dispatch''''', le second est l'étage de '''''scheduling'''''. Le ''dispatch'' analyse la µops et décide où l'envoyer, selon que c'est une µops mémoire, flottante, entière. Le ''scheduling'' gère le disponibilité des opérandes et des unités de calcul. La µop émise est ajoutée au tampon de réordonnancement, s'il existe, lors de l'étape de ''dispatch''. Cette séparation fait qu'il est possible de démarrer l'exécution de plusieurs µops simultanément : une µop par ''scheduler''. Le fait est que deux ''schedulers'' différents émettent leurs µops sans se synchroniser avec les autres. Deux ''schedulers'' peuvent donc émettre chacun une µops, ce qui fait deux µops émises lors du même cycle. Cela permet d'exécuter en même temps deux ou trois instructions 1-cycle, comme des additions, soustractions, décalages, etc. Il devient aussi possible d'émettre un µop entière en même temps qu'un µop flottante, si les µops entières ont un ''scheduler'' séparée de celle des µops flottantes. Notons que cela permet d'émettre deux µops par cycle au niveau de l'étape de ''scheduling'', pas à l'étape de ''dispatch''. Aussi, nous parlerons de '''''multi-schedule'''''. Par contre, il faut faire une précision importante : le processeur ne peut toujours qu'émettre une ''instruction'' par cycle. Le processeur charge et décode une instruction par cycle, pas plus. Pour faire une comparaison, rappelez-vous l'exécution dans le désordre simple. Elle permet d'exécuter plusieurs instructions par cycle d'horloge, si on regarde ce qui se passe au niveau des unités de calcul. Mais si on fait la moyenne sur le temps long, on ne peut pas dépasser un IPC (''instruction per cycle'') de 1. Le ''multi-scheduling'' est similaire. Il permet de gagner en performance, mais ne permet pas de dépasser un IPC de 1. Il augmente l'IPC quand il est bas, mais il ne faut que le faire remonter vers le seuil de 1. ==Les files de µops et l'exécution dans le désordre== Depuis au moins trois chapitres, je vous ai fait croire que le ''scheduler'' remplaçait la file de micro-opérations, la file de µops. Plus précisémment, que le ''scheduler'' était une file de µops améliorée. Mais dans les faits, il est parfaitement possible d'utiliser une file de µops en complément d'un ''scheduler'', voire de plusieurs. Il est même possible de faire de l'exécution dans le désordre en utilisant uniquement des files de µops. Il est temps de voir comment ce genre de ruse est possible ===L'exécution dans le désordre avec des files de µops=== Prenez un ''scheduler'' décentralisé. Une optimisation consiste à remplacer les ''schedulers'' par des files de µops. L'idée est qu'un fois décodée, les instructions sont accumulées dans des files de µops séparées. Il y a typiquement un file de micro-opération pour les opérations entières, un autre pour les opérations flottantes, un autre pour les accès mémoire. D'autres processeurs utilisent un file pour les accès mémoire et un autre pour les autres instructions, comme le faisait le Pentium 4 d'Intel. Les files de µops ont chacune leur propre ''scoreboard'' pour détecter les dépendances. Les ''scoreboard'' des différentes files de µops communiquent entre eux pour gérer le disponibilité des registres. Quand un registre est lu ou écrit par un instruction, toutes les files de µops sont prévenues et mettent à jour leurs ''scoreboards'' en même temps. Le résultat est un processeur qui implémente une forme limitée d'exécution dans le désordre. Par exemple, prenons un processeur avec trois files de µops : une en aval de l'unité mémoire, une autre en aval de la FPU, et une autre en aval de l'ALU entière. Le processeur exécute les µops mémoire dans l'ordre, les µops entières dans l'ordre, et idem pour les µops flottantes. Par contre, une µop entière peut passer avant une µop mémoire, et réciproquement. Idem avec les µops flottantes, qui peuvent passer avant ou après une µops entières, comparé à l'ordre du programme. L'exécution dans le désordre est donc partielle. L'intérêt par rapport à l'exécution dans l'ordre est que si une dépendance bloque un FIFO, les autres peuvent continuer d'émettre des instructions. Les files de micro-opération émettent leurs instructions de manière indépendante, elles ne savent pas si telle µop dans un autre file doit passer avant ou après la µop qu'elles émettent. Par exemple, le processeur Pentium 4 avait deux files de µops séparées : un pour les µops mémoire et une autre pour les autres µops. Si jamais une µops mémoire bloquait le pipeline, l'autre file de µops pouvait continuer à émettre des instructions indépendante de le lecture/écriture bloquante. Un problème est qu'il est facile de se retrouver avec toutes les files de µops bloquées. Quand l'une est bloquée, les autres tendent à se vider rapidement, particulièrement quand c'est le file pour les accès mémoire qui se bloque. Le résultat est que les gains performances sont intermédiaires entre l'exécution dans l'ordre et l'exécution dans le désordre complète. L'avantage est que les files de µops sont plus économes en termes de transistors. Les ''schedulers'' sont des circuits complexes et couteux en circuits, quand on les compare à une file de µops de même taille. Le remplacement fait donc faire des économies de circuit substantielles, qu'on peut utiliser pour agrandir la file de µops résultante. Il y a donc le choix entre trois petits ''schedulers'' et trois grandes files de µops. L'agrandissement des files de µops compense partiellement le gain lié au fait que l'exécution dans le désordre est incomplète. Pour résumer, il existe 4 grandes techniques d'exécution dans le désordre. Elles peuvent se classer sur deux critères : est-ce que les instructions sont émises dans l'ordre, et combien il y a de files de micro-opération. {|class="wikitable" |- ! ! File de µops normale ! ''scheduler'' |- ! un seule | ''Scoreboard'' | ''scheduler'' centralisée |- ! Plusieurs | Plusieurs FIFOs, plusieurs ''Scoreboard'' indépendants | ''scheduler'' décentralisé |} ===L'usage de files d'instruction en complément des ''schedulers''=== S'il est possible de remplacer des ''schedulers'' par des files de µops, il est possible d'utiliser les deux en même temps, de manière complémentaires. L'idée est de faire précéder le ''scheduler'' par une file de µops. Ainsi, si le ''scheduler'' est remplit, l'unité d'émission peut accumuler des µops dans la file de µops, sans bloquer le pipeline. La technique a été introduite par les processeurs Intel et AMD modernes, vers la moitié des années 2020. Ils ont introduits des '''''non-scheduling queue''''' sur la plupart de leurs microarchitectures. Une autre manière de voir la situation est qu'au lieu d'utiliser un gros ''scheduler'' capable d'accumuler un grand nombre de µops, on utilise un ''sheduler'' plus petit couplé à une file de µops. Utiliser un gros ''scheduler'' demanderait trop de transistors, pour un gain en performance qui ne se manifesterait que dans des situations extrêmes, où le pipeline est bloqué pendant très longtemps. Utiliser un ''scheduler'' de taille modéré, qu'on complémente avec une file de µops pour gérer ces cas extrêmes, est un meilleur compromis. Les ''non-scheduling queue'' peuvent s'utiliser avec des ''schedulers'' décentralisés. Prenons un exemple avec trois ''schedulers'', un pour les µops mémoire, un autre pour l'ALU entière, et un autre pour la FPU. Il est possible de faire précéder chaque ''scheduler'' par sa propre ''non-scheduling queue''.Mais dans les faits, ce n'est pas ce qui est fait de nos jours. Autant les ''schedulers'' de l('unité mémoire et de la FPU disposent de leur ''non-scheduling queue'', autant ce n'est pas le cas pour celui lié à l'ALU entière. La raison est que les opérations entières sont plus rapides que les opérations flottantes, et les accès mémoire. Une opération entière prend quelques cycles d'horloge, alors qu'une opération flottante atteint la dizaine de cycles et les accès mémoire la centaine. Les µops mémoire tendent donc à s'accumuler plus vite que les autres, et le ''scheduler'' de lunité mémoire se remplit très vite. Il en est de même pour le ''scheduler'' flottant, mais il se remplit moins vite et a moins de chance d'être débordé. En tout cas, le ''scheduler'' entier, s'il a une taille suffisante, ne sera jamais remplit avant les autres. Il a peu de chances d'être bloqué parce que plein, il sera bloqué car les ''non-scheduling queue'' déborderont et bloqueront le pipeline avant. ===Le découplage décodage-renommage=== Les processeurs modernes utilisent une file de µops en aval de l'unité de renommage. Elle n'a pas de lien direct avec l'exécution dans le désordre, elle ne remplace pas un ''scheduler'', ni ne le complète. A la place, elle sert en quelque sorte de lubrifiant entre le décodeur d'instruction et l'unité de renommage. Nous parlerons de '''découplage décodage-renommage''' dans ce qui suit. Voyons en quoi il consiste. Une instruction, une fois décodée, donne une ou plusieurs µops. L’unité de renommage, quant à elle, traite une µop à la fois (sauf sur les CPU superscalaires, qu'on verra dans le prochain chapitre). Il y a donc un ''mismatch'' entre sortie des décodeurs et entrée de l'unité de renommage. Pour régler ce problème, on insère la file de µops entre le décodeur et l'unité de renommage. Le décodeur peut accumuler plusieurs µops par cycle dans la file de µops, mais elles sortiront une par une. L'unité de renommage pourra donc aller à son rythme de une µop par cycle, pendant que l'unité de décodage produira autant de µops par cycle que nécessaire. La file de µops sert donc à découpler le décodage et l'unité de renommage, à savoir les faire fonctionner chacun à leur rythme. [[File:Découplage du renommage et du décodage.png|centre|vignette|upright=2|Découplage du renommage et du décodage.]] Un autre avantage de cette file de µops est qu'elle peut implémenter des optimisations qu'un ''scheduler'' ne peut pas faire. Par exemple, elle peut implémenter un ''Loop Stream Detector'', qui permet de ne pas charger/décoder les isntructions de petites boucles. Si une boucle tient complétement dans la file de µops, ses µops sont lues depuis la file de µops, et non depuis le cache. Un ''scheduler'' en est incapable. Le rôle de découplage décodage-renommage est distinct du fait d'accumuler des µops quand le ''scheduler'' est plein. En pratique, ils sont remplis par des files de µops séparées. Les processeurs modenres disposent de ''non-scheduling queue'' séparées de la file de µops proprement dite. Les ''non-scheduling queue'' sont places après l'unité de renommage de registre, alors que la file de µops est par nature avant l'unité de renommage. Les processeurs Intel, AMD, et potentiellement ARM, avaient une file de µops, mais pas de ''non-scheduling queue''. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le scoreboarding et l'algorithme de Tomasulo | nextText=Le scoreboarding et l'algorithme de Tomasulo }} </noinclude> 7mjzc8ubaz3ec20yxbkbqvrjn6ifl94 774119 774118 2026-10-04T13:14:27Z Mewtow 31375 /* Les schedulers spécialisés pour les µops flottantes */ 774119 wikitext text/x-wiki Nous venons de parler de l'exécution dans le désordre pendant deux chapitres. Les chapitres précédents vous ont laissé penser qu'un processeur avec exécution dans le désordre était assez simple : on remplace le file de µops par un ''scheduler'', on ajoute éventuellement un unité de renommage de registres, et voilà ! Et en effet, l'implémentation la plus simple de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Mais dans la réalité, les processeurs modernes n'utilisent pas un ''scheduler'' unique. Les processeurs modernes ont besoin de ''scheduler'' très grands, avec un grand nombre d'entrées. Or, nous avons vu qu'un ''scheduler'' est un sorte de mémoire associative très complexe, avec un grand nombre de comparateurs. Et cela pose problème quand le ''scheduler'' doit mémoriser un grand nombre de µops. Je reste volontairement vague sur le nombre de µops exact, car il dépend de la finesse de gravure du processeur. Plus les transistors sont petits et efficaces, plus les ''schedulers'' peuvent grossir sans trop de problème. Mais malgré la loi de Moore, il arrive que des processeurs aient besoin de ''schedulers'' plus larges que ce que peut leur offrir la technologie de l'époque. Le cout en transistors d'un ''scheduler'' n'est pas proportionnel au nombre de µops qu'elle peut mémoriser. Par exemple, si je compare un ''scheduler'' de 32 entrées (32 µops max), elle n'utilise pas deux fois plus transistors qu'un ''scheduler'' de 16 entrées. Le cout en circuit n'est pas deux fois plus élevé, mais est approximativement 4 fois plus élevé. Le cout est presque quadratique, à savoir qu'il est proportionnel au carré du nombre d'entrées. Et il en est de même avec le performance. Pour un grand nombre de µops, un ''scheduler'' centralisé n'est pas très pratique. Il consomme beaucoup de transistors, chauffe beaucoup, n'est pas très rapide. La solution est de scinder un gros ''scheduler'' en plusieurs ''schedulers'' plus petits. Pour faire la différence avec un ''scheduler'' unique, nous allons parler de ''scheduler'' centralisé s'il n'y en a qu'un, de '''''scheduler'' décentralisé''' s'il y en a plusieurs. ==La spécialisation des ''schedulers'' décentralisés== La scission du ''scheduler'' doit idéalement se faire en tenant compte du reste du processeur. En effet, il faut relier les ''schedulers'' aux unités de calcul, ainsi qu'à l'unité mémoire, et cela ne peut pas se faire n'importe comment. Reste à voir comment faire. Précisons d'or et déjà que la scission dépend de si le processeur supporte l'exécution dans le désordre des accès mémoire. Si le processeur accepte de faire les accès mémoire dans le désordre, alors la scission sera différente. ===Les ''schedulers'' spécialisés pour les µops flottantes=== Prenons un processeur où les accès mémoire sont réalisés dans l'ordre. Le processeur a donc un simple ''scheduler'' pour les µops entières, les µops flottantes et les µops de branchements. La scission la plus basique sépare un ''scheduler'' pour les µops de calcul entières, d'un ''scheduler'' pour les µops flottantes. Il est aussi possible de séparer le ''scheduler'' pour les µops entières en deux : un pour les opérations de calcul et un autre pour les µops de branchements. La séparation est moins évidente, mais reste possible. Pour le moment, concentrons-nous sur la séparation du ''scheduler'' flottant et du ''scheduler entier''. [[File:Processeur avec des fenêtres d'instruction décentralisées.png|centre|vignette|upright=2|Processeur avec des fenêtres d'instruction décentralisées]] L'avantage est que l'implémentation est très simple. Les flottants sont gérés à part des entiers. Ils ont leurs propres registres, des FPU séparées des ALU entières, bref : le séparation est complète. Utiliser un ''scheduler'' séparée va dans ce sens. Un avantage de cette scission se situe au niveau de la consommation d'électricité. Si le programme exécuté n'exécute aucune instruction flottante, le ''scheduler'' pour µops flottant peut être ''clock gate'', mis en veille, voire éteint. Le processeur consomme donc moins d'électricité, et chauffe moins. Remarque importante : les deux ''schedulers'' travaillent indépendamment les unes des autres. Ils décident d'émettre une µops à chaque cycle, dans leur coin, sans rendre de compte à l'autre. La conséquence est qu'il est possible d'émettre deux µops par cycle, si les circonstances l'autorisent. Le ''scheduler'' entier émet une µops entière en même temps que le ''scheduler'' flottant émet une µops flottante. Les deux µops démarrent au même cycle, on n'émet pas l'une après l'autre. Le résultat est un gain en performance, sur lequel on reviendra plus tard. Un avantage de cette séparation est qu'elle permet de renommer les registres flottants et entiers séparément. Non pas que ce soit impossible avec un ''scheduler'' unique, mais c'est beaucoup plus compliqué et rarement fait. En séparant ''scheduler'' entier et ''scheduler'' flottant, les deux peuvent être précédés de deux unités de renommage séparées. Et à vrai dire, on peut même aller plus loin, en n'autorisant l'exécution dans le désordre que pour les opérations flottantes, ou au contraire que pour les opérations entières. La toute première implémentation de l'exécution dans le désordre, sur l'IBM 360, en est un bon exemple. La FPU de ce processeur était précédée par un ''scheduler'', mais les ALu entières n'étaient précédés que par une file de µops basique, sans exécution dans le désordre. L'implémentation peut utiliser la ''lecture après émission'' ou ''avant émission''. Dans les deux cas, les registres entiers et flottants sont lus en parallèle. Ainsi, si une µop entière et une µop flottante sont émises en même temps, les opérandes de la µop entière sont lues depuis le banc de registres généraux, les opérandes de la µop flottante sont lus depuis les registres flottants, les deux lectures se font en même temps. Par contre, cela a un impact sur le ''scheduler''. Avec la ''lecture avant émission'', il doit avoir assez de ports pour insérer 4 opérandes dans deux µops. [[File:Processeur avec plusieurs fenêtres d'instruction.png|centre|vignette|upright=2.5|Processeur avec un ''scheduler'' flottant, avec lecture après émission.]] [[File:Read Before Issue.png|centre|vignette|upright=2.5|Processeur avec un ''scheduler'' flottant, avec lecture avant émission.]] ===Les ''schedulers'' spécialisés pour les calculs d'adresse=== Une autre scission, aussi naturelle que la précédente, serait d'utiliser un ''scheduler'' spécialisé pour les µops mémoire, et un ''scheduler'' pour le reste des µops. Mais cela ne marcherait pas, pour une raison que vous ne deviez pas oublier : les µops mémoire doivent se faire dans l'ordre du programme, sauf à utiliser des techniques de désambiguïsation mémoire. Par contre, il est possible de séparer les calculs d'adresse des autres opérations entières. Dans ce qui suit, on port du principe que les µops mémoire sont scindées en deux : une µop de calcul d'adresse, et une µops mémoire. La première calcule l'adresse utilisée par la seconde, la seconde exécute l'accès mémoire dans le cache. La séparation permet d'exécuter les calculs d'adresse dans le désordre, mais les µops mémoire se font elles dans l'ordre. Le transfert de l'adresse calculée peut se faire de plusieurs manières. Pour simplifier, on va supposer qu'elle se fait à travers un ''registre d'adresse'' "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. L'avantage est que les calculs d'adresse sont fait plus tôt, qu'avec un ''scheduler'' centralisé. Par exemple, on ne se retrouve plus dans une situation où plusieurs µops entières passer devant une µop de calcul d'adresse. Les µops entières et de calcul d'adresse sont prêtes, mais les µops entières sont exécutées en premier, la µop de calcul d'adresse est mise en attente pendant quelques cycles. Et non seulement les calculs d'adresse sont émis au plus tôt, mais ils ont de plus des ports de lecture dédié sur le banc de registres. Et effectuer les calculs d'adresse rapidement est primordial, car cela permet de connaitre à l'avance les adresses, donc d’effectuer les lectures le plus tôt possible. ===Les stations de réservation=== Un cas extrême est celui où chaque ALU/FPU a sa propre ''scheduler''. Par exemple, il y a un ''scheduler'' pour l'ALU entière, un autre pour le multiplieur entier, un autre pour le ''barrel shifter'', un autre pour l'unité mémoire, un autre pour l'unité de branchements, un autre pour l'additionneur flottant, un autre pour le multiplier flottant, etc. Un exemple historique est celui de le FPU de l'IBM 360 modèle 90, qui avait un additionneur flottant, un multiplieur flottant, et une unité mémoire pour les lectures/écritures flottantes. Les trois unités avaient leur propre ''scheduler''. Un exemple aussi extrême n'est pas si rare. Les processeurs AMD utilisent beaucoup cette technique, pour les ALU entières, à quelques nuances près. A l'opposé, Intel préfère utiliser des ''schedulers'' centralisés sur les anciens processeurs, deux ou trois ''schedulers'' sur les processeurs récents (2020 et plus). Les processeurs AMD utilisaient précisément un ''scheduler'' par ALU entière, alors que les FPUs partagent un ''scheduler'' flottante unique. Mais nous verrons cela en détail dans le chapitre sur les processeurs superscalaires x86. Toujours est-il qu'un cas aussi unique porte un nom. Les ''schedulers'' reliés à une seule ALU (entière ou flottante) sont appelés des '''stations de réservation''', ''Reservation Stations'' en anglais. Le terme a été introduit dans l'article de Tomasulo, qui décrivait justement la FPU de l'IBM 360, mentionnée plus haut. Mais dans ce cours, le terme décrit des ''schedulers'' qui mémorisent les opérandes des instructions, pas seulement les noms de registres associés. [[File:Contournement avec des stations de réservation.png|centre|vignette|upright=2.5|Stations de réservation]] Toujours est-il que l'exécution dans le désordre avec des stations de réservations a un intérêt historique fort. Elle a été utilisée dans deux techniques d'exécution dans le désordre très ancienne : l'algorithme de Tomasulo et le ''Scoreboarding'' du processeur CDC 6600. Ces deux implémentations ont introduit la technique, qui a été raffinée par la suite. Leur défaut est qu'elles ne géraient pas les exceptions précises, ce qui fait qu'on verra ces deux techniques dans le chapitre suivant, qui leur est dédié. ==Les ''schedulers'' décentralisés versus centralisés== Le choix entre un ''scheduler'' centralisés et plusieurs petits ''schedulers'' décentralisés est un compromis entre économie de circuits et flexibilité. Un gros ''scheduler'' unique est plus flexible, ce qui peut entrainer des gains de performance, mais coute beaucoup en circuits et peut réduire la fréquence du processeur. ===Un ''scheduler'' centralisé est plus flexible, mais plus couteux=== Pour expliquer en quoi un gros ''scheduler'' unique est plus flexible, nous allons prendre un exemple. Comparons un ''scheduler'' de 64 entrées, et deux ''schedulers'' de 32 entrées chacun. Les deux ''schedulers'' sont spécialisé :l'un est spécialisé dans les µops entières, l'autre dans les µops flottantes. En tout, les deux ''schedulers'' peuvent mémoriser au maximum 32 µops entières et 32 µops flottantes. A l'opposé, le gros ''scheduler'' unique peut mémoriser 64 µops, peu importe leur nature. Si le programme exécuté n'utilise pas de nombres flottants, le différence devient flagrante. La solution à deux ''schedulers'' est limitée à 32 µops, alors que le ''scheduler'' centralisé peut mémoriser le double. Les performances seront donc meilleures avec le ''scheduler'' centralisé. Et au-delà de ça, le ''scheduler'' décentralisé est utilisé de manière optimale si la moitié des µops sont des µops entières, et l'autre moitié des µops flottantes. Alors que le ''scheduler'' centralisé se moque de la répartition entre µops entières et flottantes. Pour le dire autrement, avec un ''scheduler'' décentralisé, il arrive qu'un ''scheduler'' soit remplis, alors que les autres sont sous-utilisés, voire vides. Ce qui n'arrive pas avec un ''scheduler'' centralisé. Par contre, les ''schedulers'' décentralisés utilisent plusieurs ''schedulers'' de "petite taille", plus rapides, plus économes en circuits et économes en énergie. Le compromis n'est donc pas entre un ''scheduler'' à 64 entrées versus deux ''schedulers'' de 32 entrées. Le compromis en pratique est entre un ''scheduler'' de 64 entrées versus 3 ''schedulers'' de 32 entrées. Un exemple pratique est celui de le microarchitecture ''Skyleke'' d'Intel. Intel est passé d'un ''scheduler'' centralisé pre-''Skyleke'' à deux ''schedulers'' sur ''Skyleke'', mais le nombre total d'entrées était globalement le même entre les deux, même si l'évolution de le finesse de gravure entre les deux biaise un peu les résultats. ===L'émission avec un ''scheduler'' décentralisé : ''dispatch'' et ''schedule''=== Un point important est qu'avec des ''schedulers'' décentralisés, l'émission est découpée en deux étages. Le premier étage envoie une µops vers le ''scheduler'' adéquat, le seconde émet la µops quand ses opérandes sont prêts. La première étape fait rentrer une µops dans le ''scheduler'', la seconde l'en fait sortir. Le premier étage est l'étage de '''''dispatch''''', le second est l'étage de '''''scheduling'''''. Le ''dispatch'' analyse la µops et décide où l'envoyer, selon que c'est une µops mémoire, flottante, entière. Le ''scheduling'' gère le disponibilité des opérandes et des unités de calcul. La µop émise est ajoutée au tampon de réordonnancement, s'il existe, lors de l'étape de ''dispatch''. Cette séparation fait qu'il est possible de démarrer l'exécution de plusieurs µops simultanément : une µop par ''scheduler''. Le fait est que deux ''schedulers'' différents émettent leurs µops sans se synchroniser avec les autres. Deux ''schedulers'' peuvent donc émettre chacun une µops, ce qui fait deux µops émises lors du même cycle. Cela permet d'exécuter en même temps deux ou trois instructions 1-cycle, comme des additions, soustractions, décalages, etc. Il devient aussi possible d'émettre un µop entière en même temps qu'un µop flottante, si les µops entières ont un ''scheduler'' séparée de celle des µops flottantes. Notons que cela permet d'émettre deux µops par cycle au niveau de l'étape de ''scheduling'', pas à l'étape de ''dispatch''. Aussi, nous parlerons de '''''multi-schedule'''''. Par contre, il faut faire une précision importante : le processeur ne peut toujours qu'émettre une ''instruction'' par cycle. Le processeur charge et décode une instruction par cycle, pas plus. Pour faire une comparaison, rappelez-vous l'exécution dans le désordre simple. Elle permet d'exécuter plusieurs instructions par cycle d'horloge, si on regarde ce qui se passe au niveau des unités de calcul. Mais si on fait la moyenne sur le temps long, on ne peut pas dépasser un IPC (''instruction per cycle'') de 1. Le ''multi-scheduling'' est similaire. Il permet de gagner en performance, mais ne permet pas de dépasser un IPC de 1. Il augmente l'IPC quand il est bas, mais il ne faut que le faire remonter vers le seuil de 1. ==Les files de µops et l'exécution dans le désordre== Depuis au moins trois chapitres, je vous ai fait croire que le ''scheduler'' remplaçait la file de micro-opérations, la file de µops. Plus précisémment, que le ''scheduler'' était une file de µops améliorée. Mais dans les faits, il est parfaitement possible d'utiliser une file de µops en complément d'un ''scheduler'', voire de plusieurs. Il est même possible de faire de l'exécution dans le désordre en utilisant uniquement des files de µops. Il est temps de voir comment ce genre de ruse est possible ===L'exécution dans le désordre avec des files de µops=== Prenez un ''scheduler'' décentralisé. Une optimisation consiste à remplacer les ''schedulers'' par des files de µops. L'idée est qu'un fois décodée, les instructions sont accumulées dans des files de µops séparées. Il y a typiquement un file de micro-opération pour les opérations entières, un autre pour les opérations flottantes, un autre pour les accès mémoire. D'autres processeurs utilisent un file pour les accès mémoire et un autre pour les autres instructions, comme le faisait le Pentium 4 d'Intel. Les files de µops ont chacune leur propre ''scoreboard'' pour détecter les dépendances. Les ''scoreboard'' des différentes files de µops communiquent entre eux pour gérer le disponibilité des registres. Quand un registre est lu ou écrit par un instruction, toutes les files de µops sont prévenues et mettent à jour leurs ''scoreboards'' en même temps. Le résultat est un processeur qui implémente une forme limitée d'exécution dans le désordre. Par exemple, prenons un processeur avec trois files de µops : une en aval de l'unité mémoire, une autre en aval de la FPU, et une autre en aval de l'ALU entière. Le processeur exécute les µops mémoire dans l'ordre, les µops entières dans l'ordre, et idem pour les µops flottantes. Par contre, une µop entière peut passer avant une µop mémoire, et réciproquement. Idem avec les µops flottantes, qui peuvent passer avant ou après une µops entières, comparé à l'ordre du programme. L'exécution dans le désordre est donc partielle. L'intérêt par rapport à l'exécution dans l'ordre est que si une dépendance bloque un FIFO, les autres peuvent continuer d'émettre des instructions. Les files de micro-opération émettent leurs instructions de manière indépendante, elles ne savent pas si telle µop dans un autre file doit passer avant ou après la µop qu'elles émettent. Par exemple, le processeur Pentium 4 avait deux files de µops séparées : un pour les µops mémoire et une autre pour les autres µops. Si jamais une µops mémoire bloquait le pipeline, l'autre file de µops pouvait continuer à émettre des instructions indépendante de le lecture/écriture bloquante. Un problème est qu'il est facile de se retrouver avec toutes les files de µops bloquées. Quand l'une est bloquée, les autres tendent à se vider rapidement, particulièrement quand c'est le file pour les accès mémoire qui se bloque. Le résultat est que les gains performances sont intermédiaires entre l'exécution dans l'ordre et l'exécution dans le désordre complète. L'avantage est que les files de µops sont plus économes en termes de transistors. Les ''schedulers'' sont des circuits complexes et couteux en circuits, quand on les compare à une file de µops de même taille. Le remplacement fait donc faire des économies de circuit substantielles, qu'on peut utiliser pour agrandir la file de µops résultante. Il y a donc le choix entre trois petits ''schedulers'' et trois grandes files de µops. L'agrandissement des files de µops compense partiellement le gain lié au fait que l'exécution dans le désordre est incomplète. Pour résumer, il existe 4 grandes techniques d'exécution dans le désordre. Elles peuvent se classer sur deux critères : est-ce que les instructions sont émises dans l'ordre, et combien il y a de files de micro-opération. {|class="wikitable" |- ! ! File de µops normale ! ''scheduler'' |- ! un seule | ''Scoreboard'' | ''scheduler'' centralisée |- ! Plusieurs | Plusieurs FIFOs, plusieurs ''Scoreboard'' indépendants | ''scheduler'' décentralisé |} ===L'usage de files d'instruction en complément des ''schedulers''=== S'il est possible de remplacer des ''schedulers'' par des files de µops, il est possible d'utiliser les deux en même temps, de manière complémentaires. L'idée est de faire précéder le ''scheduler'' par une file de µops. Ainsi, si le ''scheduler'' est remplit, l'unité d'émission peut accumuler des µops dans la file de µops, sans bloquer le pipeline. La technique a été introduite par les processeurs Intel et AMD modernes, vers la moitié des années 2020. Ils ont introduits des '''''non-scheduling queue''''' sur la plupart de leurs microarchitectures. Une autre manière de voir la situation est qu'au lieu d'utiliser un gros ''scheduler'' capable d'accumuler un grand nombre de µops, on utilise un ''sheduler'' plus petit couplé à une file de µops. Utiliser un gros ''scheduler'' demanderait trop de transistors, pour un gain en performance qui ne se manifesterait que dans des situations extrêmes, où le pipeline est bloqué pendant très longtemps. Utiliser un ''scheduler'' de taille modéré, qu'on complémente avec une file de µops pour gérer ces cas extrêmes, est un meilleur compromis. Les ''non-scheduling queue'' peuvent s'utiliser avec des ''schedulers'' décentralisés. Prenons un exemple avec trois ''schedulers'', un pour les µops mémoire, un autre pour l'ALU entière, et un autre pour la FPU. Il est possible de faire précéder chaque ''scheduler'' par sa propre ''non-scheduling queue''.Mais dans les faits, ce n'est pas ce qui est fait de nos jours. Autant les ''schedulers'' de l('unité mémoire et de la FPU disposent de leur ''non-scheduling queue'', autant ce n'est pas le cas pour celui lié à l'ALU entière. La raison est que les opérations entières sont plus rapides que les opérations flottantes, et les accès mémoire. Une opération entière prend quelques cycles d'horloge, alors qu'une opération flottante atteint la dizaine de cycles et les accès mémoire la centaine. Les µops mémoire tendent donc à s'accumuler plus vite que les autres, et le ''scheduler'' de lunité mémoire se remplit très vite. Il en est de même pour le ''scheduler'' flottant, mais il se remplit moins vite et a moins de chance d'être débordé. En tout cas, le ''scheduler'' entier, s'il a une taille suffisante, ne sera jamais remplit avant les autres. Il a peu de chances d'être bloqué parce que plein, il sera bloqué car les ''non-scheduling queue'' déborderont et bloqueront le pipeline avant. ===Le découplage décodage-renommage=== Les processeurs modernes utilisent une file de µops en aval de l'unité de renommage. Elle n'a pas de lien direct avec l'exécution dans le désordre, elle ne remplace pas un ''scheduler'', ni ne le complète. A la place, elle sert en quelque sorte de lubrifiant entre le décodeur d'instruction et l'unité de renommage. Nous parlerons de '''découplage décodage-renommage''' dans ce qui suit. Voyons en quoi il consiste. Une instruction, une fois décodée, donne une ou plusieurs µops. L’unité de renommage, quant à elle, traite une µop à la fois (sauf sur les CPU superscalaires, qu'on verra dans le prochain chapitre). Il y a donc un ''mismatch'' entre sortie des décodeurs et entrée de l'unité de renommage. Pour régler ce problème, on insère la file de µops entre le décodeur et l'unité de renommage. Le décodeur peut accumuler plusieurs µops par cycle dans la file de µops, mais elles sortiront une par une. L'unité de renommage pourra donc aller à son rythme de une µop par cycle, pendant que l'unité de décodage produira autant de µops par cycle que nécessaire. La file de µops sert donc à découpler le décodage et l'unité de renommage, à savoir les faire fonctionner chacun à leur rythme. [[File:Découplage du renommage et du décodage.png|centre|vignette|upright=2|Découplage du renommage et du décodage.]] Un autre avantage de cette file de µops est qu'elle peut implémenter des optimisations qu'un ''scheduler'' ne peut pas faire. Par exemple, elle peut implémenter un ''Loop Stream Detector'', qui permet de ne pas charger/décoder les isntructions de petites boucles. Si une boucle tient complétement dans la file de µops, ses µops sont lues depuis la file de µops, et non depuis le cache. Un ''scheduler'' en est incapable. Le rôle de découplage décodage-renommage est distinct du fait d'accumuler des µops quand le ''scheduler'' est plein. En pratique, ils sont remplis par des files de µops séparées. Les processeurs modenres disposent de ''non-scheduling queue'' séparées de la file de µops proprement dite. Les ''non-scheduling queue'' sont places après l'unité de renommage de registre, alors que la file de µops est par nature avant l'unité de renommage. Les processeurs Intel, AMD, et potentiellement ARM, avaient une file de µops, mais pas de ''non-scheduling queue''. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le scoreboarding et l'algorithme de Tomasulo | nextText=Le scoreboarding et l'algorithme de Tomasulo }} </noinclude> b1yv0njvjzdromtbhrxlhgnaqvpzaxe 774120 774119 2026-10-04T13:14:48Z Mewtow 31375 /* Les schedulers spécialisés pour les µops flottantes */ 774120 wikitext text/x-wiki Nous venons de parler de l'exécution dans le désordre pendant deux chapitres. Les chapitres précédents vous ont laissé penser qu'un processeur avec exécution dans le désordre était assez simple : on remplace le file de µops par un ''scheduler'', on ajoute éventuellement un unité de renommage de registres, et voilà ! Et en effet, l'implémentation la plus simple de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Mais dans la réalité, les processeurs modernes n'utilisent pas un ''scheduler'' unique. Les processeurs modernes ont besoin de ''scheduler'' très grands, avec un grand nombre d'entrées. Or, nous avons vu qu'un ''scheduler'' est un sorte de mémoire associative très complexe, avec un grand nombre de comparateurs. Et cela pose problème quand le ''scheduler'' doit mémoriser un grand nombre de µops. Je reste volontairement vague sur le nombre de µops exact, car il dépend de la finesse de gravure du processeur. Plus les transistors sont petits et efficaces, plus les ''schedulers'' peuvent grossir sans trop de problème. Mais malgré la loi de Moore, il arrive que des processeurs aient besoin de ''schedulers'' plus larges que ce que peut leur offrir la technologie de l'époque. Le cout en transistors d'un ''scheduler'' n'est pas proportionnel au nombre de µops qu'elle peut mémoriser. Par exemple, si je compare un ''scheduler'' de 32 entrées (32 µops max), elle n'utilise pas deux fois plus transistors qu'un ''scheduler'' de 16 entrées. Le cout en circuit n'est pas deux fois plus élevé, mais est approximativement 4 fois plus élevé. Le cout est presque quadratique, à savoir qu'il est proportionnel au carré du nombre d'entrées. Et il en est de même avec le performance. Pour un grand nombre de µops, un ''scheduler'' centralisé n'est pas très pratique. Il consomme beaucoup de transistors, chauffe beaucoup, n'est pas très rapide. La solution est de scinder un gros ''scheduler'' en plusieurs ''schedulers'' plus petits. Pour faire la différence avec un ''scheduler'' unique, nous allons parler de ''scheduler'' centralisé s'il n'y en a qu'un, de '''''scheduler'' décentralisé''' s'il y en a plusieurs. ==La spécialisation des ''schedulers'' décentralisés== La scission du ''scheduler'' doit idéalement se faire en tenant compte du reste du processeur. En effet, il faut relier les ''schedulers'' aux unités de calcul, ainsi qu'à l'unité mémoire, et cela ne peut pas se faire n'importe comment. Reste à voir comment faire. Précisons d'or et déjà que la scission dépend de si le processeur supporte l'exécution dans le désordre des accès mémoire. Si le processeur accepte de faire les accès mémoire dans le désordre, alors la scission sera différente. ===Les ''schedulers'' spécialisés pour les µops flottantes=== Prenons un processeur où les accès mémoire sont réalisés dans l'ordre. Le processeur a donc un simple ''scheduler'' pour les µops entières, les µops flottantes et les µops de branchements. La scission la plus basique sépare un ''scheduler'' pour les µops de calcul entières, d'un ''scheduler'' pour les µops flottantes. Il est aussi possible de séparer le ''scheduler'' pour les µops entières en deux : un pour les opérations de calcul et un autre pour les µops de branchements. La séparation est moins évidente, mais reste possible. Pour le moment, concentrons-nous sur la séparation du ''scheduler'' flottant et du ''scheduler entier''. [[File:Processeur avec des fenêtres d'instruction décentralisées.png|centre|vignette|upright=2|Processeur avec des fenêtres d'instruction décentralisées]] L'avantage est que l'implémentation est très simple. Les flottants sont gérés à part des entiers. Ils ont leurs propres registres, des FPU séparées des ALU entières, bref : le séparation est complète. Utiliser un ''scheduler'' séparée va dans ce sens. Un avantage de cette scission se situe au niveau de la consommation d'électricité. Si le programme exécuté n'exécute aucune instruction flottante, le ''scheduler'' pour µops flottant peut être ''clock gate'', mis en veille, voire éteint. Le processeur consomme donc moins d'électricité, et chauffe moins. Remarque importante : les deux ''schedulers'' travaillent indépendamment les unes des autres. Ils décident d'émettre une µops à chaque cycle, dans leur coin, sans rendre de compte à l'autre. La conséquence est qu'il est possible d'émettre deux µops par cycle, si les circonstances l'autorisent. Le ''scheduler'' entier émet une µops entière en même temps que le ''scheduler'' flottant émet une µops flottante. Les deux µops démarrent au même cycle, on n'émet pas l'une après l'autre. Le résultat est un gain en performance, sur lequel on reviendra plus tard. L'implémentation peut utiliser la ''lecture après émission'' ou ''avant émission''. Dans les deux cas, les registres entiers et flottants sont lus en parallèle. Ainsi, si une µop entière et une µop flottante sont émises en même temps, les opérandes de la µop entière sont lues depuis le banc de registres généraux, les opérandes de la µop flottante sont lus depuis les registres flottants, les deux lectures se font en même temps. Par contre, cela a un impact sur le ''scheduler''. Avec la ''lecture avant émission'', il doit avoir assez de ports pour insérer 4 opérandes dans deux µops. [[File:Processeur avec plusieurs fenêtres d'instruction.png|centre|vignette|upright=2.5|Processeur avec un ''scheduler'' flottant, avec lecture après émission.]] [[File:Read Before Issue.png|centre|vignette|upright=2.5|Processeur avec un ''scheduler'' flottant, avec lecture avant émission.]] Un avantage de cette séparation est qu'elle permet de renommer les registres flottants et entiers séparément. Non pas que ce soit impossible avec un ''scheduler'' unique, mais c'est beaucoup plus compliqué et rarement fait. En séparant ''scheduler'' entier et ''scheduler'' flottant, les deux peuvent être précédés de deux unités de renommage séparées. Et à vrai dire, on peut même aller plus loin, en n'autorisant l'exécution dans le désordre que pour les opérations flottantes, ou au contraire que pour les opérations entières. La toute première implémentation de l'exécution dans le désordre, sur l'IBM 360, en est un bon exemple. La FPU de ce processeur était précédée par un ''scheduler'', mais les ALu entières n'étaient précédés que par une file de µops basique, sans exécution dans le désordre. ===Les ''schedulers'' spécialisés pour les calculs d'adresse=== Une autre scission, aussi naturelle que la précédente, serait d'utiliser un ''scheduler'' spécialisé pour les µops mémoire, et un ''scheduler'' pour le reste des µops. Mais cela ne marcherait pas, pour une raison que vous ne deviez pas oublier : les µops mémoire doivent se faire dans l'ordre du programme, sauf à utiliser des techniques de désambiguïsation mémoire. Par contre, il est possible de séparer les calculs d'adresse des autres opérations entières. Dans ce qui suit, on port du principe que les µops mémoire sont scindées en deux : une µop de calcul d'adresse, et une µops mémoire. La première calcule l'adresse utilisée par la seconde, la seconde exécute l'accès mémoire dans le cache. La séparation permet d'exécuter les calculs d'adresse dans le désordre, mais les µops mémoire se font elles dans l'ordre. Le transfert de l'adresse calculée peut se faire de plusieurs manières. Pour simplifier, on va supposer qu'elle se fait à travers un ''registre d'adresse'' "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. L'avantage est que les calculs d'adresse sont fait plus tôt, qu'avec un ''scheduler'' centralisé. Par exemple, on ne se retrouve plus dans une situation où plusieurs µops entières passer devant une µop de calcul d'adresse. Les µops entières et de calcul d'adresse sont prêtes, mais les µops entières sont exécutées en premier, la µop de calcul d'adresse est mise en attente pendant quelques cycles. Et non seulement les calculs d'adresse sont émis au plus tôt, mais ils ont de plus des ports de lecture dédié sur le banc de registres. Et effectuer les calculs d'adresse rapidement est primordial, car cela permet de connaitre à l'avance les adresses, donc d’effectuer les lectures le plus tôt possible. ===Les stations de réservation=== Un cas extrême est celui où chaque ALU/FPU a sa propre ''scheduler''. Par exemple, il y a un ''scheduler'' pour l'ALU entière, un autre pour le multiplieur entier, un autre pour le ''barrel shifter'', un autre pour l'unité mémoire, un autre pour l'unité de branchements, un autre pour l'additionneur flottant, un autre pour le multiplier flottant, etc. Un exemple historique est celui de le FPU de l'IBM 360 modèle 90, qui avait un additionneur flottant, un multiplieur flottant, et une unité mémoire pour les lectures/écritures flottantes. Les trois unités avaient leur propre ''scheduler''. Un exemple aussi extrême n'est pas si rare. Les processeurs AMD utilisent beaucoup cette technique, pour les ALU entières, à quelques nuances près. A l'opposé, Intel préfère utiliser des ''schedulers'' centralisés sur les anciens processeurs, deux ou trois ''schedulers'' sur les processeurs récents (2020 et plus). Les processeurs AMD utilisaient précisément un ''scheduler'' par ALU entière, alors que les FPUs partagent un ''scheduler'' flottante unique. Mais nous verrons cela en détail dans le chapitre sur les processeurs superscalaires x86. Toujours est-il qu'un cas aussi unique porte un nom. Les ''schedulers'' reliés à une seule ALU (entière ou flottante) sont appelés des '''stations de réservation''', ''Reservation Stations'' en anglais. Le terme a été introduit dans l'article de Tomasulo, qui décrivait justement la FPU de l'IBM 360, mentionnée plus haut. Mais dans ce cours, le terme décrit des ''schedulers'' qui mémorisent les opérandes des instructions, pas seulement les noms de registres associés. [[File:Contournement avec des stations de réservation.png|centre|vignette|upright=2.5|Stations de réservation]] Toujours est-il que l'exécution dans le désordre avec des stations de réservations a un intérêt historique fort. Elle a été utilisée dans deux techniques d'exécution dans le désordre très ancienne : l'algorithme de Tomasulo et le ''Scoreboarding'' du processeur CDC 6600. Ces deux implémentations ont introduit la technique, qui a été raffinée par la suite. Leur défaut est qu'elles ne géraient pas les exceptions précises, ce qui fait qu'on verra ces deux techniques dans le chapitre suivant, qui leur est dédié. ==Les ''schedulers'' décentralisés versus centralisés== Le choix entre un ''scheduler'' centralisés et plusieurs petits ''schedulers'' décentralisés est un compromis entre économie de circuits et flexibilité. Un gros ''scheduler'' unique est plus flexible, ce qui peut entrainer des gains de performance, mais coute beaucoup en circuits et peut réduire la fréquence du processeur. ===Un ''scheduler'' centralisé est plus flexible, mais plus couteux=== Pour expliquer en quoi un gros ''scheduler'' unique est plus flexible, nous allons prendre un exemple. Comparons un ''scheduler'' de 64 entrées, et deux ''schedulers'' de 32 entrées chacun. Les deux ''schedulers'' sont spécialisé :l'un est spécialisé dans les µops entières, l'autre dans les µops flottantes. En tout, les deux ''schedulers'' peuvent mémoriser au maximum 32 µops entières et 32 µops flottantes. A l'opposé, le gros ''scheduler'' unique peut mémoriser 64 µops, peu importe leur nature. Si le programme exécuté n'utilise pas de nombres flottants, le différence devient flagrante. La solution à deux ''schedulers'' est limitée à 32 µops, alors que le ''scheduler'' centralisé peut mémoriser le double. Les performances seront donc meilleures avec le ''scheduler'' centralisé. Et au-delà de ça, le ''scheduler'' décentralisé est utilisé de manière optimale si la moitié des µops sont des µops entières, et l'autre moitié des µops flottantes. Alors que le ''scheduler'' centralisé se moque de la répartition entre µops entières et flottantes. Pour le dire autrement, avec un ''scheduler'' décentralisé, il arrive qu'un ''scheduler'' soit remplis, alors que les autres sont sous-utilisés, voire vides. Ce qui n'arrive pas avec un ''scheduler'' centralisé. Par contre, les ''schedulers'' décentralisés utilisent plusieurs ''schedulers'' de "petite taille", plus rapides, plus économes en circuits et économes en énergie. Le compromis n'est donc pas entre un ''scheduler'' à 64 entrées versus deux ''schedulers'' de 32 entrées. Le compromis en pratique est entre un ''scheduler'' de 64 entrées versus 3 ''schedulers'' de 32 entrées. Un exemple pratique est celui de le microarchitecture ''Skyleke'' d'Intel. Intel est passé d'un ''scheduler'' centralisé pre-''Skyleke'' à deux ''schedulers'' sur ''Skyleke'', mais le nombre total d'entrées était globalement le même entre les deux, même si l'évolution de le finesse de gravure entre les deux biaise un peu les résultats. ===L'émission avec un ''scheduler'' décentralisé : ''dispatch'' et ''schedule''=== Un point important est qu'avec des ''schedulers'' décentralisés, l'émission est découpée en deux étages. Le premier étage envoie une µops vers le ''scheduler'' adéquat, le seconde émet la µops quand ses opérandes sont prêts. La première étape fait rentrer une µops dans le ''scheduler'', la seconde l'en fait sortir. Le premier étage est l'étage de '''''dispatch''''', le second est l'étage de '''''scheduling'''''. Le ''dispatch'' analyse la µops et décide où l'envoyer, selon que c'est une µops mémoire, flottante, entière. Le ''scheduling'' gère le disponibilité des opérandes et des unités de calcul. La µop émise est ajoutée au tampon de réordonnancement, s'il existe, lors de l'étape de ''dispatch''. Cette séparation fait qu'il est possible de démarrer l'exécution de plusieurs µops simultanément : une µop par ''scheduler''. Le fait est que deux ''schedulers'' différents émettent leurs µops sans se synchroniser avec les autres. Deux ''schedulers'' peuvent donc émettre chacun une µops, ce qui fait deux µops émises lors du même cycle. Cela permet d'exécuter en même temps deux ou trois instructions 1-cycle, comme des additions, soustractions, décalages, etc. Il devient aussi possible d'émettre un µop entière en même temps qu'un µop flottante, si les µops entières ont un ''scheduler'' séparée de celle des µops flottantes. Notons que cela permet d'émettre deux µops par cycle au niveau de l'étape de ''scheduling'', pas à l'étape de ''dispatch''. Aussi, nous parlerons de '''''multi-schedule'''''. Par contre, il faut faire une précision importante : le processeur ne peut toujours qu'émettre une ''instruction'' par cycle. Le processeur charge et décode une instruction par cycle, pas plus. Pour faire une comparaison, rappelez-vous l'exécution dans le désordre simple. Elle permet d'exécuter plusieurs instructions par cycle d'horloge, si on regarde ce qui se passe au niveau des unités de calcul. Mais si on fait la moyenne sur le temps long, on ne peut pas dépasser un IPC (''instruction per cycle'') de 1. Le ''multi-scheduling'' est similaire. Il permet de gagner en performance, mais ne permet pas de dépasser un IPC de 1. Il augmente l'IPC quand il est bas, mais il ne faut que le faire remonter vers le seuil de 1. ==Les files de µops et l'exécution dans le désordre== Depuis au moins trois chapitres, je vous ai fait croire que le ''scheduler'' remplaçait la file de micro-opérations, la file de µops. Plus précisémment, que le ''scheduler'' était une file de µops améliorée. Mais dans les faits, il est parfaitement possible d'utiliser une file de µops en complément d'un ''scheduler'', voire de plusieurs. Il est même possible de faire de l'exécution dans le désordre en utilisant uniquement des files de µops. Il est temps de voir comment ce genre de ruse est possible ===L'exécution dans le désordre avec des files de µops=== Prenez un ''scheduler'' décentralisé. Une optimisation consiste à remplacer les ''schedulers'' par des files de µops. L'idée est qu'un fois décodée, les instructions sont accumulées dans des files de µops séparées. Il y a typiquement un file de micro-opération pour les opérations entières, un autre pour les opérations flottantes, un autre pour les accès mémoire. D'autres processeurs utilisent un file pour les accès mémoire et un autre pour les autres instructions, comme le faisait le Pentium 4 d'Intel. Les files de µops ont chacune leur propre ''scoreboard'' pour détecter les dépendances. Les ''scoreboard'' des différentes files de µops communiquent entre eux pour gérer le disponibilité des registres. Quand un registre est lu ou écrit par un instruction, toutes les files de µops sont prévenues et mettent à jour leurs ''scoreboards'' en même temps. Le résultat est un processeur qui implémente une forme limitée d'exécution dans le désordre. Par exemple, prenons un processeur avec trois files de µops : une en aval de l'unité mémoire, une autre en aval de la FPU, et une autre en aval de l'ALU entière. Le processeur exécute les µops mémoire dans l'ordre, les µops entières dans l'ordre, et idem pour les µops flottantes. Par contre, une µop entière peut passer avant une µop mémoire, et réciproquement. Idem avec les µops flottantes, qui peuvent passer avant ou après une µops entières, comparé à l'ordre du programme. L'exécution dans le désordre est donc partielle. L'intérêt par rapport à l'exécution dans l'ordre est que si une dépendance bloque un FIFO, les autres peuvent continuer d'émettre des instructions. Les files de micro-opération émettent leurs instructions de manière indépendante, elles ne savent pas si telle µop dans un autre file doit passer avant ou après la µop qu'elles émettent. Par exemple, le processeur Pentium 4 avait deux files de µops séparées : un pour les µops mémoire et une autre pour les autres µops. Si jamais une µops mémoire bloquait le pipeline, l'autre file de µops pouvait continuer à émettre des instructions indépendante de le lecture/écriture bloquante. Un problème est qu'il est facile de se retrouver avec toutes les files de µops bloquées. Quand l'une est bloquée, les autres tendent à se vider rapidement, particulièrement quand c'est le file pour les accès mémoire qui se bloque. Le résultat est que les gains performances sont intermédiaires entre l'exécution dans l'ordre et l'exécution dans le désordre complète. L'avantage est que les files de µops sont plus économes en termes de transistors. Les ''schedulers'' sont des circuits complexes et couteux en circuits, quand on les compare à une file de µops de même taille. Le remplacement fait donc faire des économies de circuit substantielles, qu'on peut utiliser pour agrandir la file de µops résultante. Il y a donc le choix entre trois petits ''schedulers'' et trois grandes files de µops. L'agrandissement des files de µops compense partiellement le gain lié au fait que l'exécution dans le désordre est incomplète. Pour résumer, il existe 4 grandes techniques d'exécution dans le désordre. Elles peuvent se classer sur deux critères : est-ce que les instructions sont émises dans l'ordre, et combien il y a de files de micro-opération. {|class="wikitable" |- ! ! File de µops normale ! ''scheduler'' |- ! un seule | ''Scoreboard'' | ''scheduler'' centralisée |- ! Plusieurs | Plusieurs FIFOs, plusieurs ''Scoreboard'' indépendants | ''scheduler'' décentralisé |} ===L'usage de files d'instruction en complément des ''schedulers''=== S'il est possible de remplacer des ''schedulers'' par des files de µops, il est possible d'utiliser les deux en même temps, de manière complémentaires. L'idée est de faire précéder le ''scheduler'' par une file de µops. Ainsi, si le ''scheduler'' est remplit, l'unité d'émission peut accumuler des µops dans la file de µops, sans bloquer le pipeline. La technique a été introduite par les processeurs Intel et AMD modernes, vers la moitié des années 2020. Ils ont introduits des '''''non-scheduling queue''''' sur la plupart de leurs microarchitectures. Une autre manière de voir la situation est qu'au lieu d'utiliser un gros ''scheduler'' capable d'accumuler un grand nombre de µops, on utilise un ''sheduler'' plus petit couplé à une file de µops. Utiliser un gros ''scheduler'' demanderait trop de transistors, pour un gain en performance qui ne se manifesterait que dans des situations extrêmes, où le pipeline est bloqué pendant très longtemps. Utiliser un ''scheduler'' de taille modéré, qu'on complémente avec une file de µops pour gérer ces cas extrêmes, est un meilleur compromis. Les ''non-scheduling queue'' peuvent s'utiliser avec des ''schedulers'' décentralisés. Prenons un exemple avec trois ''schedulers'', un pour les µops mémoire, un autre pour l'ALU entière, et un autre pour la FPU. Il est possible de faire précéder chaque ''scheduler'' par sa propre ''non-scheduling queue''.Mais dans les faits, ce n'est pas ce qui est fait de nos jours. Autant les ''schedulers'' de l('unité mémoire et de la FPU disposent de leur ''non-scheduling queue'', autant ce n'est pas le cas pour celui lié à l'ALU entière. La raison est que les opérations entières sont plus rapides que les opérations flottantes, et les accès mémoire. Une opération entière prend quelques cycles d'horloge, alors qu'une opération flottante atteint la dizaine de cycles et les accès mémoire la centaine. Les µops mémoire tendent donc à s'accumuler plus vite que les autres, et le ''scheduler'' de lunité mémoire se remplit très vite. Il en est de même pour le ''scheduler'' flottant, mais il se remplit moins vite et a moins de chance d'être débordé. En tout cas, le ''scheduler'' entier, s'il a une taille suffisante, ne sera jamais remplit avant les autres. Il a peu de chances d'être bloqué parce que plein, il sera bloqué car les ''non-scheduling queue'' déborderont et bloqueront le pipeline avant. ===Le découplage décodage-renommage=== Les processeurs modernes utilisent une file de µops en aval de l'unité de renommage. Elle n'a pas de lien direct avec l'exécution dans le désordre, elle ne remplace pas un ''scheduler'', ni ne le complète. A la place, elle sert en quelque sorte de lubrifiant entre le décodeur d'instruction et l'unité de renommage. Nous parlerons de '''découplage décodage-renommage''' dans ce qui suit. Voyons en quoi il consiste. Une instruction, une fois décodée, donne une ou plusieurs µops. L’unité de renommage, quant à elle, traite une µop à la fois (sauf sur les CPU superscalaires, qu'on verra dans le prochain chapitre). Il y a donc un ''mismatch'' entre sortie des décodeurs et entrée de l'unité de renommage. Pour régler ce problème, on insère la file de µops entre le décodeur et l'unité de renommage. Le décodeur peut accumuler plusieurs µops par cycle dans la file de µops, mais elles sortiront une par une. L'unité de renommage pourra donc aller à son rythme de une µop par cycle, pendant que l'unité de décodage produira autant de µops par cycle que nécessaire. La file de µops sert donc à découpler le décodage et l'unité de renommage, à savoir les faire fonctionner chacun à leur rythme. [[File:Découplage du renommage et du décodage.png|centre|vignette|upright=2|Découplage du renommage et du décodage.]] Un autre avantage de cette file de µops est qu'elle peut implémenter des optimisations qu'un ''scheduler'' ne peut pas faire. Par exemple, elle peut implémenter un ''Loop Stream Detector'', qui permet de ne pas charger/décoder les isntructions de petites boucles. Si une boucle tient complétement dans la file de µops, ses µops sont lues depuis la file de µops, et non depuis le cache. Un ''scheduler'' en est incapable. Le rôle de découplage décodage-renommage est distinct du fait d'accumuler des µops quand le ''scheduler'' est plein. En pratique, ils sont remplis par des files de µops séparées. Les processeurs modenres disposent de ''non-scheduling queue'' séparées de la file de µops proprement dite. Les ''non-scheduling queue'' sont places après l'unité de renommage de registre, alors que la file de µops est par nature avant l'unité de renommage. Les processeurs Intel, AMD, et potentiellement ARM, avaient une file de µops, mais pas de ''non-scheduling queue''. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le scoreboarding et l'algorithme de Tomasulo | nextText=Le scoreboarding et l'algorithme de Tomasulo }} </noinclude> gtnhkzxpbir7jkg8zrlzd7x0917fzwy 774130 774120 2026-10-04T13:33:20Z Mewtow 31375 /* Les schedulers spécialisés pour les calculs d'adresse */ 774130 wikitext text/x-wiki Nous venons de parler de l'exécution dans le désordre pendant deux chapitres. Les chapitres précédents vous ont laissé penser qu'un processeur avec exécution dans le désordre était assez simple : on remplace le file de µops par un ''scheduler'', on ajoute éventuellement un unité de renommage de registres, et voilà ! Et en effet, l'implémentation la plus simple de l'OOO utilise un ''scheduler'' unique, appelée un '''''scheduler'' centralisé'''. Mais dans la réalité, les processeurs modernes n'utilisent pas un ''scheduler'' unique. Les processeurs modernes ont besoin de ''scheduler'' très grands, avec un grand nombre d'entrées. Or, nous avons vu qu'un ''scheduler'' est un sorte de mémoire associative très complexe, avec un grand nombre de comparateurs. Et cela pose problème quand le ''scheduler'' doit mémoriser un grand nombre de µops. Je reste volontairement vague sur le nombre de µops exact, car il dépend de la finesse de gravure du processeur. Plus les transistors sont petits et efficaces, plus les ''schedulers'' peuvent grossir sans trop de problème. Mais malgré la loi de Moore, il arrive que des processeurs aient besoin de ''schedulers'' plus larges que ce que peut leur offrir la technologie de l'époque. Le cout en transistors d'un ''scheduler'' n'est pas proportionnel au nombre de µops qu'elle peut mémoriser. Par exemple, si je compare un ''scheduler'' de 32 entrées (32 µops max), elle n'utilise pas deux fois plus transistors qu'un ''scheduler'' de 16 entrées. Le cout en circuit n'est pas deux fois plus élevé, mais est approximativement 4 fois plus élevé. Le cout est presque quadratique, à savoir qu'il est proportionnel au carré du nombre d'entrées. Et il en est de même avec le performance. Pour un grand nombre de µops, un ''scheduler'' centralisé n'est pas très pratique. Il consomme beaucoup de transistors, chauffe beaucoup, n'est pas très rapide. La solution est de scinder un gros ''scheduler'' en plusieurs ''schedulers'' plus petits. Pour faire la différence avec un ''scheduler'' unique, nous allons parler de ''scheduler'' centralisé s'il n'y en a qu'un, de '''''scheduler'' décentralisé''' s'il y en a plusieurs. ==La spécialisation des ''schedulers'' décentralisés== La scission du ''scheduler'' doit idéalement se faire en tenant compte du reste du processeur. En effet, il faut relier les ''schedulers'' aux unités de calcul, ainsi qu'à l'unité mémoire, et cela ne peut pas se faire n'importe comment. Reste à voir comment faire. Précisons d'or et déjà que la scission dépend de si le processeur supporte l'exécution dans le désordre des accès mémoire. Si le processeur accepte de faire les accès mémoire dans le désordre, alors la scission sera différente. ===Les ''schedulers'' spécialisés pour les µops flottantes=== Prenons un processeur où les accès mémoire sont réalisés dans l'ordre. Le processeur a donc un simple ''scheduler'' pour les µops entières, les µops flottantes et les µops de branchements. La scission la plus basique sépare un ''scheduler'' pour les µops de calcul entières, d'un ''scheduler'' pour les µops flottantes. Il est aussi possible de séparer le ''scheduler'' pour les µops entières en deux : un pour les opérations de calcul et un autre pour les µops de branchements. La séparation est moins évidente, mais reste possible. Pour le moment, concentrons-nous sur la séparation du ''scheduler'' flottant et du ''scheduler entier''. [[File:Processeur avec des fenêtres d'instruction décentralisées.png|centre|vignette|upright=2|Processeur avec des fenêtres d'instruction décentralisées]] L'avantage est que l'implémentation est très simple. Les flottants sont gérés à part des entiers. Ils ont leurs propres registres, des FPU séparées des ALU entières, bref : le séparation est complète. Utiliser un ''scheduler'' séparée va dans ce sens. Un avantage de cette scission se situe au niveau de la consommation d'électricité. Si le programme exécuté n'exécute aucune instruction flottante, le ''scheduler'' pour µops flottant peut être ''clock gate'', mis en veille, voire éteint. Le processeur consomme donc moins d'électricité, et chauffe moins. Remarque importante : les deux ''schedulers'' travaillent indépendamment les unes des autres. Ils décident d'émettre une µops à chaque cycle, dans leur coin, sans rendre de compte à l'autre. La conséquence est qu'il est possible d'émettre deux µops par cycle, si les circonstances l'autorisent. Le ''scheduler'' entier émet une µops entière en même temps que le ''scheduler'' flottant émet une µops flottante. Les deux µops démarrent au même cycle, on n'émet pas l'une après l'autre. Le résultat est un gain en performance, sur lequel on reviendra plus tard. L'implémentation peut utiliser la ''lecture après émission'' ou ''avant émission''. Dans les deux cas, les registres entiers et flottants sont lus en parallèle. Ainsi, si une µop entière et une µop flottante sont émises en même temps, les opérandes de la µop entière sont lues depuis le banc de registres généraux, les opérandes de la µop flottante sont lus depuis les registres flottants, les deux lectures se font en même temps. Par contre, cela a un impact sur le ''scheduler''. Avec la ''lecture avant émission'', il doit avoir assez de ports pour insérer 4 opérandes dans deux µops. [[File:Processeur avec plusieurs fenêtres d'instruction.png|centre|vignette|upright=2.5|Processeur avec un ''scheduler'' flottant, avec lecture après émission.]] [[File:Read Before Issue.png|centre|vignette|upright=2.5|Processeur avec un ''scheduler'' flottant, avec lecture avant émission.]] Un avantage de cette séparation est qu'elle permet de renommer les registres flottants et entiers séparément. Non pas que ce soit impossible avec un ''scheduler'' unique, mais c'est beaucoup plus compliqué et rarement fait. En séparant ''scheduler'' entier et ''scheduler'' flottant, les deux peuvent être précédés de deux unités de renommage séparées. Et à vrai dire, on peut même aller plus loin, en n'autorisant l'exécution dans le désordre que pour les opérations flottantes, ou au contraire que pour les opérations entières. La toute première implémentation de l'exécution dans le désordre, sur l'IBM 360, en est un bon exemple. La FPU de ce processeur était précédée par un ''scheduler'', mais les ALu entières n'étaient précédés que par une file de µops basique, sans exécution dans le désordre. ===Les ''schedulers'' spécialisés pour les calculs d'adresse=== Une autre scission, aussi naturelle que la précédente, serait d'utiliser un ''scheduler'' spécialisé pour les µops mémoire, et un ''scheduler'' pour le reste des µops. Mais cela ne marcherait pas, pour une raison que vous ne deviez pas oublier : les µops mémoire doivent se faire dans l'ordre du programme, sauf à utiliser des techniques de désambiguïsation mémoire. Par contre, il est possible de séparer les calculs d'adresse des autres opérations entières. Dans ce qui suit, on port du principe que les µops mémoire sont scindées en deux : une µop de calcul d'adresse, et une µops mémoire. La première calcule l'adresse utilisée par la seconde, la seconde exécute l'accès mémoire dans le cache. La séparation permet d'exécuter les calculs d'adresse dans le désordre, mais les µops mémoire se font elles dans l'ordre. Le transfert de l'adresse calculée peut se faire de plusieurs manières, mais nous en parlerons en détail dans le chapitre sur la désambiguïsation mémoire. Pour le moment, on suppose qu'elle se fait à travers un ''registre d'adresse'' "caché", dans le sens où il n'est pas un registre architectural nommé. La µop de calcul d'adresse enregistre son résultat dans ce registre, la µop mémoire lit ce registre après émission. Le registre d'adresse est souvent un registre physique, que l'unité de renommage attribue comme registre de destination du calcul d'adresse. L'avantage est que les calculs d'adresse sont fait plus tôt, qu'avec un ''scheduler'' centralisé. Par exemple, on ne se retrouve plus dans une situation où plusieurs µops entières passer devant une µop de calcul d'adresse. Les µops entières et de calcul d'adresse sont prêtes, mais les µops entières sont exécutées en premier, la µop de calcul d'adresse est mise en attente pendant quelques cycles. Et non seulement les calculs d'adresse sont émis au plus tôt, mais ils ont de plus des ports de lecture dédié sur le banc de registres. Et effectuer les calculs d'adresse rapidement est primordial, car cela permet de connaitre à l'avance les adresses, donc d’effectuer les lectures le plus tôt possible. ===Les stations de réservation=== Un cas extrême est celui où chaque ALU/FPU a sa propre ''scheduler''. Par exemple, il y a un ''scheduler'' pour l'ALU entière, un autre pour le multiplieur entier, un autre pour le ''barrel shifter'', un autre pour l'unité mémoire, un autre pour l'unité de branchements, un autre pour l'additionneur flottant, un autre pour le multiplier flottant, etc. Un exemple historique est celui de le FPU de l'IBM 360 modèle 90, qui avait un additionneur flottant, un multiplieur flottant, et une unité mémoire pour les lectures/écritures flottantes. Les trois unités avaient leur propre ''scheduler''. Un exemple aussi extrême n'est pas si rare. Les processeurs AMD utilisent beaucoup cette technique, pour les ALU entières, à quelques nuances près. A l'opposé, Intel préfère utiliser des ''schedulers'' centralisés sur les anciens processeurs, deux ou trois ''schedulers'' sur les processeurs récents (2020 et plus). Les processeurs AMD utilisaient précisément un ''scheduler'' par ALU entière, alors que les FPUs partagent un ''scheduler'' flottante unique. Mais nous verrons cela en détail dans le chapitre sur les processeurs superscalaires x86. Toujours est-il qu'un cas aussi unique porte un nom. Les ''schedulers'' reliés à une seule ALU (entière ou flottante) sont appelés des '''stations de réservation''', ''Reservation Stations'' en anglais. Le terme a été introduit dans l'article de Tomasulo, qui décrivait justement la FPU de l'IBM 360, mentionnée plus haut. Mais dans ce cours, le terme décrit des ''schedulers'' qui mémorisent les opérandes des instructions, pas seulement les noms de registres associés. [[File:Contournement avec des stations de réservation.png|centre|vignette|upright=2.5|Stations de réservation]] Toujours est-il que l'exécution dans le désordre avec des stations de réservations a un intérêt historique fort. Elle a été utilisée dans deux techniques d'exécution dans le désordre très ancienne : l'algorithme de Tomasulo et le ''Scoreboarding'' du processeur CDC 6600. Ces deux implémentations ont introduit la technique, qui a été raffinée par la suite. Leur défaut est qu'elles ne géraient pas les exceptions précises, ce qui fait qu'on verra ces deux techniques dans le chapitre suivant, qui leur est dédié. ==Les ''schedulers'' décentralisés versus centralisés== Le choix entre un ''scheduler'' centralisés et plusieurs petits ''schedulers'' décentralisés est un compromis entre économie de circuits et flexibilité. Un gros ''scheduler'' unique est plus flexible, ce qui peut entrainer des gains de performance, mais coute beaucoup en circuits et peut réduire la fréquence du processeur. ===Un ''scheduler'' centralisé est plus flexible, mais plus couteux=== Pour expliquer en quoi un gros ''scheduler'' unique est plus flexible, nous allons prendre un exemple. Comparons un ''scheduler'' de 64 entrées, et deux ''schedulers'' de 32 entrées chacun. Les deux ''schedulers'' sont spécialisé :l'un est spécialisé dans les µops entières, l'autre dans les µops flottantes. En tout, les deux ''schedulers'' peuvent mémoriser au maximum 32 µops entières et 32 µops flottantes. A l'opposé, le gros ''scheduler'' unique peut mémoriser 64 µops, peu importe leur nature. Si le programme exécuté n'utilise pas de nombres flottants, le différence devient flagrante. La solution à deux ''schedulers'' est limitée à 32 µops, alors que le ''scheduler'' centralisé peut mémoriser le double. Les performances seront donc meilleures avec le ''scheduler'' centralisé. Et au-delà de ça, le ''scheduler'' décentralisé est utilisé de manière optimale si la moitié des µops sont des µops entières, et l'autre moitié des µops flottantes. Alors que le ''scheduler'' centralisé se moque de la répartition entre µops entières et flottantes. Pour le dire autrement, avec un ''scheduler'' décentralisé, il arrive qu'un ''scheduler'' soit remplis, alors que les autres sont sous-utilisés, voire vides. Ce qui n'arrive pas avec un ''scheduler'' centralisé. Par contre, les ''schedulers'' décentralisés utilisent plusieurs ''schedulers'' de "petite taille", plus rapides, plus économes en circuits et économes en énergie. Le compromis n'est donc pas entre un ''scheduler'' à 64 entrées versus deux ''schedulers'' de 32 entrées. Le compromis en pratique est entre un ''scheduler'' de 64 entrées versus 3 ''schedulers'' de 32 entrées. Un exemple pratique est celui de le microarchitecture ''Skyleke'' d'Intel. Intel est passé d'un ''scheduler'' centralisé pre-''Skyleke'' à deux ''schedulers'' sur ''Skyleke'', mais le nombre total d'entrées était globalement le même entre les deux, même si l'évolution de le finesse de gravure entre les deux biaise un peu les résultats. ===L'émission avec un ''scheduler'' décentralisé : ''dispatch'' et ''schedule''=== Un point important est qu'avec des ''schedulers'' décentralisés, l'émission est découpée en deux étages. Le premier étage envoie une µops vers le ''scheduler'' adéquat, le seconde émet la µops quand ses opérandes sont prêts. La première étape fait rentrer une µops dans le ''scheduler'', la seconde l'en fait sortir. Le premier étage est l'étage de '''''dispatch''''', le second est l'étage de '''''scheduling'''''. Le ''dispatch'' analyse la µops et décide où l'envoyer, selon que c'est une µops mémoire, flottante, entière. Le ''scheduling'' gère le disponibilité des opérandes et des unités de calcul. La µop émise est ajoutée au tampon de réordonnancement, s'il existe, lors de l'étape de ''dispatch''. Cette séparation fait qu'il est possible de démarrer l'exécution de plusieurs µops simultanément : une µop par ''scheduler''. Le fait est que deux ''schedulers'' différents émettent leurs µops sans se synchroniser avec les autres. Deux ''schedulers'' peuvent donc émettre chacun une µops, ce qui fait deux µops émises lors du même cycle. Cela permet d'exécuter en même temps deux ou trois instructions 1-cycle, comme des additions, soustractions, décalages, etc. Il devient aussi possible d'émettre un µop entière en même temps qu'un µop flottante, si les µops entières ont un ''scheduler'' séparée de celle des µops flottantes. Notons que cela permet d'émettre deux µops par cycle au niveau de l'étape de ''scheduling'', pas à l'étape de ''dispatch''. Aussi, nous parlerons de '''''multi-schedule'''''. Par contre, il faut faire une précision importante : le processeur ne peut toujours qu'émettre une ''instruction'' par cycle. Le processeur charge et décode une instruction par cycle, pas plus. Pour faire une comparaison, rappelez-vous l'exécution dans le désordre simple. Elle permet d'exécuter plusieurs instructions par cycle d'horloge, si on regarde ce qui se passe au niveau des unités de calcul. Mais si on fait la moyenne sur le temps long, on ne peut pas dépasser un IPC (''instruction per cycle'') de 1. Le ''multi-scheduling'' est similaire. Il permet de gagner en performance, mais ne permet pas de dépasser un IPC de 1. Il augmente l'IPC quand il est bas, mais il ne faut que le faire remonter vers le seuil de 1. ==Les files de µops et l'exécution dans le désordre== Depuis au moins trois chapitres, je vous ai fait croire que le ''scheduler'' remplaçait la file de micro-opérations, la file de µops. Plus précisémment, que le ''scheduler'' était une file de µops améliorée. Mais dans les faits, il est parfaitement possible d'utiliser une file de µops en complément d'un ''scheduler'', voire de plusieurs. Il est même possible de faire de l'exécution dans le désordre en utilisant uniquement des files de µops. Il est temps de voir comment ce genre de ruse est possible ===L'exécution dans le désordre avec des files de µops=== Prenez un ''scheduler'' décentralisé. Une optimisation consiste à remplacer les ''schedulers'' par des files de µops. L'idée est qu'un fois décodée, les instructions sont accumulées dans des files de µops séparées. Il y a typiquement un file de micro-opération pour les opérations entières, un autre pour les opérations flottantes, un autre pour les accès mémoire. D'autres processeurs utilisent un file pour les accès mémoire et un autre pour les autres instructions, comme le faisait le Pentium 4 d'Intel. Les files de µops ont chacune leur propre ''scoreboard'' pour détecter les dépendances. Les ''scoreboard'' des différentes files de µops communiquent entre eux pour gérer le disponibilité des registres. Quand un registre est lu ou écrit par un instruction, toutes les files de µops sont prévenues et mettent à jour leurs ''scoreboards'' en même temps. Le résultat est un processeur qui implémente une forme limitée d'exécution dans le désordre. Par exemple, prenons un processeur avec trois files de µops : une en aval de l'unité mémoire, une autre en aval de la FPU, et une autre en aval de l'ALU entière. Le processeur exécute les µops mémoire dans l'ordre, les µops entières dans l'ordre, et idem pour les µops flottantes. Par contre, une µop entière peut passer avant une µop mémoire, et réciproquement. Idem avec les µops flottantes, qui peuvent passer avant ou après une µops entières, comparé à l'ordre du programme. L'exécution dans le désordre est donc partielle. L'intérêt par rapport à l'exécution dans l'ordre est que si une dépendance bloque un FIFO, les autres peuvent continuer d'émettre des instructions. Les files de micro-opération émettent leurs instructions de manière indépendante, elles ne savent pas si telle µop dans un autre file doit passer avant ou après la µop qu'elles émettent. Par exemple, le processeur Pentium 4 avait deux files de µops séparées : un pour les µops mémoire et une autre pour les autres µops. Si jamais une µops mémoire bloquait le pipeline, l'autre file de µops pouvait continuer à émettre des instructions indépendante de le lecture/écriture bloquante. Un problème est qu'il est facile de se retrouver avec toutes les files de µops bloquées. Quand l'une est bloquée, les autres tendent à se vider rapidement, particulièrement quand c'est le file pour les accès mémoire qui se bloque. Le résultat est que les gains performances sont intermédiaires entre l'exécution dans l'ordre et l'exécution dans le désordre complète. L'avantage est que les files de µops sont plus économes en termes de transistors. Les ''schedulers'' sont des circuits complexes et couteux en circuits, quand on les compare à une file de µops de même taille. Le remplacement fait donc faire des économies de circuit substantielles, qu'on peut utiliser pour agrandir la file de µops résultante. Il y a donc le choix entre trois petits ''schedulers'' et trois grandes files de µops. L'agrandissement des files de µops compense partiellement le gain lié au fait que l'exécution dans le désordre est incomplète. Pour résumer, il existe 4 grandes techniques d'exécution dans le désordre. Elles peuvent se classer sur deux critères : est-ce que les instructions sont émises dans l'ordre, et combien il y a de files de micro-opération. {|class="wikitable" |- ! ! File de µops normale ! ''scheduler'' |- ! un seule | ''Scoreboard'' | ''scheduler'' centralisée |- ! Plusieurs | Plusieurs FIFOs, plusieurs ''Scoreboard'' indépendants | ''scheduler'' décentralisé |} ===L'usage de files d'instruction en complément des ''schedulers''=== S'il est possible de remplacer des ''schedulers'' par des files de µops, il est possible d'utiliser les deux en même temps, de manière complémentaires. L'idée est de faire précéder le ''scheduler'' par une file de µops. Ainsi, si le ''scheduler'' est remplit, l'unité d'émission peut accumuler des µops dans la file de µops, sans bloquer le pipeline. La technique a été introduite par les processeurs Intel et AMD modernes, vers la moitié des années 2020. Ils ont introduits des '''''non-scheduling queue''''' sur la plupart de leurs microarchitectures. Une autre manière de voir la situation est qu'au lieu d'utiliser un gros ''scheduler'' capable d'accumuler un grand nombre de µops, on utilise un ''sheduler'' plus petit couplé à une file de µops. Utiliser un gros ''scheduler'' demanderait trop de transistors, pour un gain en performance qui ne se manifesterait que dans des situations extrêmes, où le pipeline est bloqué pendant très longtemps. Utiliser un ''scheduler'' de taille modéré, qu'on complémente avec une file de µops pour gérer ces cas extrêmes, est un meilleur compromis. Les ''non-scheduling queue'' peuvent s'utiliser avec des ''schedulers'' décentralisés. Prenons un exemple avec trois ''schedulers'', un pour les µops mémoire, un autre pour l'ALU entière, et un autre pour la FPU. Il est possible de faire précéder chaque ''scheduler'' par sa propre ''non-scheduling queue''.Mais dans les faits, ce n'est pas ce qui est fait de nos jours. Autant les ''schedulers'' de l('unité mémoire et de la FPU disposent de leur ''non-scheduling queue'', autant ce n'est pas le cas pour celui lié à l'ALU entière. La raison est que les opérations entières sont plus rapides que les opérations flottantes, et les accès mémoire. Une opération entière prend quelques cycles d'horloge, alors qu'une opération flottante atteint la dizaine de cycles et les accès mémoire la centaine. Les µops mémoire tendent donc à s'accumuler plus vite que les autres, et le ''scheduler'' de lunité mémoire se remplit très vite. Il en est de même pour le ''scheduler'' flottant, mais il se remplit moins vite et a moins de chance d'être débordé. En tout cas, le ''scheduler'' entier, s'il a une taille suffisante, ne sera jamais remplit avant les autres. Il a peu de chances d'être bloqué parce que plein, il sera bloqué car les ''non-scheduling queue'' déborderont et bloqueront le pipeline avant. ===Le découplage décodage-renommage=== Les processeurs modernes utilisent une file de µops en aval de l'unité de renommage. Elle n'a pas de lien direct avec l'exécution dans le désordre, elle ne remplace pas un ''scheduler'', ni ne le complète. A la place, elle sert en quelque sorte de lubrifiant entre le décodeur d'instruction et l'unité de renommage. Nous parlerons de '''découplage décodage-renommage''' dans ce qui suit. Voyons en quoi il consiste. Une instruction, une fois décodée, donne une ou plusieurs µops. L’unité de renommage, quant à elle, traite une µop à la fois (sauf sur les CPU superscalaires, qu'on verra dans le prochain chapitre). Il y a donc un ''mismatch'' entre sortie des décodeurs et entrée de l'unité de renommage. Pour régler ce problème, on insère la file de µops entre le décodeur et l'unité de renommage. Le décodeur peut accumuler plusieurs µops par cycle dans la file de µops, mais elles sortiront une par une. L'unité de renommage pourra donc aller à son rythme de une µop par cycle, pendant que l'unité de décodage produira autant de µops par cycle que nécessaire. La file de µops sert donc à découpler le décodage et l'unité de renommage, à savoir les faire fonctionner chacun à leur rythme. [[File:Découplage du renommage et du décodage.png|centre|vignette|upright=2|Découplage du renommage et du décodage.]] Un autre avantage de cette file de µops est qu'elle peut implémenter des optimisations qu'un ''scheduler'' ne peut pas faire. Par exemple, elle peut implémenter un ''Loop Stream Detector'', qui permet de ne pas charger/décoder les isntructions de petites boucles. Si une boucle tient complétement dans la file de µops, ses µops sont lues depuis la file de µops, et non depuis le cache. Un ''scheduler'' en est incapable. Le rôle de découplage décodage-renommage est distinct du fait d'accumuler des µops quand le ''scheduler'' est plein. En pratique, ils sont remplis par des files de µops séparées. Les processeurs modenres disposent de ''non-scheduling queue'' séparées de la file de µops proprement dite. Les ''non-scheduling queue'' sont places après l'unité de renommage de registre, alors que la file de µops est par nature avant l'unité de renommage. Les processeurs Intel, AMD, et potentiellement ARM, avaient une file de µops, mais pas de ''non-scheduling queue''. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Le scoreboarding et l'algorithme de Tomasulo | prevText=Le scoreboarding et l'algorithme de Tomasulo | next=Le scoreboarding et l'algorithme de Tomasulo | nextText=Le scoreboarding et l'algorithme de Tomasulo }} </noinclude> m6s6nxoba5tx6qjav17f7fv3f9rf797 Mathc complexes/0a2 0 84542 774125 774075 2026-10-04T13:27:37Z Xhungab 23827 774125 wikitext text/x-wiki [[Catégorie:Mathc complexes (livre)]] [[Mathc complexes/a277#Les fichiers principaux :| Application]] Soit le signal : s[n] = [+2,+4,-1,+3] Calculons S(k), la transformée de Fourier discrète : Nous avons '''la transformée de Fourier discrète (TFD)''': _ N-1 \ S(k) = | s[n] exp(-2ikPI n/N) pour 0 <= k < N /_n=0 Avec N = 4 _ 3 \ S(k) = | s[n] exp(-2 i k PI n/4) /_n=0 '''Pour k = 0;''' s[n] = [+2,+4,-1,+3] '''(k) s[n] exp(-2i (k) PI(n)/4)''' S(0) = (+2) exp(-2i (0) PI(0)/4) + | (+2)exp(0) (+4) exp(-2i (0) PI(1)/4) + | (+4)exp(0) (-1) exp(-2i (0) PI(2)/4) + | (-1)exp(0) (+3) exp(-2i (0) PI(3)/4) + | (+3)exp(0) '''[[Mathc complexes/0a4|exp(0)=1]]''' Donc S(0) = +2+4-1+3 '''S(0) = 8''' '''Pour k = 1;''' s[n] = [+2,+4,-1,+3] '''(k) s[n] exp(-2i (k) PI(n)/4)''' S(1) = (+2) exp(-2i (1) PI(0)/4) + | (+2)exp(0) (+4) exp(-2i (1) PI(1)/4) + | (+4)exp(-i PI/2) (-1) exp(-2i (1) PI(2)/4) + | (-1)exp(-i PI) (+3) exp(-2i (1) PI(3)/4) + | (+3)exp(-i 3PI/2) exp(0) =(1); exp(-i PI/2) = (-i); exp(-i PI) = (-1); exp(-i 3PI/2) = (i); Donc S(1) = +2(1)+4(-i)-1(-1)+3(i) S(1) = +2 -4i +1 +3i '''S(1) = 3 - i''' '''Pour k = 2;''' s[n] = [+2,+4,-1,+3] '''(k) s[n] exp(-2i (k) PI(n)/4)''' S(2) = (+2) exp(-2i (2) PI(0)/4) + | (+2)exp(0) (+4) exp(-2i (2) PI(1)/4) + | (+4)exp(-i PI) (-1) exp(-2i (2) PI(2)/4) + | (-1)exp(-i 2PI) (+3) exp(-2i (2) PI(3)/4) + | (+3)exp(-i 3PI) exp(0) =(1); exp(-i PI)= (-1); exp(-i 2PI) = (1); exp(-i 3PI) = (-1) Donc S(2) = +2(1)+4(-1)-1(1)+3(-1) S(2) = +2 -4 -1 -3 '''S(2) = -6''' '''Pour k = 3;''' s[n] = [+2,+4,-1,+3] '''(k) s[n] exp(-2i (k) PI(n)/4)''' S(3) = (+2) exp(-2i (3) PI(0)/4) + | (+2)exp(0) (+4) exp(-2i (3) PI(1)/4) + | (+4)exp(-i 3PI/2) (-1) exp(-2i (3) PI(2)/4) + | (-1)exp(-i 3PI) (+3) exp(-2i (3) PI(3)/4) + | (+3)exp(-i 9PI/2) exp(0 =(1); exp(-i 3PI/2) = (i); exp(-i 3PI)= (-1); exp(-i 9PI/2) = (-i) Donc S(3) = +2(1)+4(i)-1(-1)+3(-i) S(3) = +2 +4i +1 -3i '''S(3) = 3 +i''' La TFD complète : Pour : s[n] = [+2,+4,-1,+3] Nous avons obtenu : S(0) = +8 S(1) = +3 - i S(2) = -6 S(3) = +3 + i Avec les matrices : S = DFT4 * s : +8.000+0.000i +3.000-1.000i -6.000+0.000i +3.000+1.000i {{AutoCat}} kw76sfxlmp2ahxduzge39ft7oesodwc 774190 774125 2026-10-05T08:22:58Z Xhungab 23827 774190 wikitext text/x-wiki [[Catégorie:Mathc complexes (livre)]] [[Mathc complexes/a277#Les fichiers principaux :| Application]] Soit le signal : s[n] = [+2,+4,-1,+3] Calculons S(k), la transformée de Fourier discrète : Nous avons '''la transformée de Fourier discrète (TFD)''': _ N-1 \ S(k) = | s[n] exp(-2ikPI n/N) pour 0 <= k < N /_n=0 Avec N = 4 _ 3 \ S(k) = | s[n] exp(-2 i k PI n/4) /_n=0 '''Pour k = 0;''' s[n] = [+2,+4,-1,+3] '''(k) s[n] exp(-2i (k) PI(n)/4)''' S(0) = (+2) exp(-2i (0) PI(0)/4) + | (+2)exp(0) + (+4) exp(-2i (0) PI(1)/4) + | (+4)exp(0) + (-1) exp(-2i (0) PI(2)/4) + | (-1)exp(0) + (+3) exp(-2i (0) PI(3)/4) | (+3)exp(0) '''[[Mathc complexes/0a4|exp(0)=1]]''' Donc S(0) = +2+4-1+3 '''S(0) = 8''' '''Pour k = 1;''' s[n] = [+2,+4,-1,+3] '''(k) s[n] exp(-2i (k) PI(n)/4)''' S(1) = (+2) exp(-2i (1) PI(0)/4) + | (+2)exp(0) + (+4) exp(-2i (1) PI(1)/4) + | (+4)exp(-i PI/2) + (-1) exp(-2i (1) PI(2)/4) + | (-1)exp(-i PI) + (+3) exp(-2i (1) PI(3)/4) | (+3)exp(-i 3PI/2) exp(0) =(1); exp(-i PI/2) = (-i); exp(-i PI) = (-1); exp(-i 3PI/2) = (i); Donc S(1) = +2(1)+4(-i)-1(-1)+3(i) S(1) = +2 -4i +1 +3i '''S(1) = 3 - i''' '''Pour k = 2;''' s[n] = [+2,+4,-1,+3] '''(k) s[n] exp(-2i (k) PI(n)/4)''' S(2) = (+2) exp(-2i (2) PI(0)/4) + | (+2)exp(0) + (+4) exp(-2i (2) PI(1)/4) + | (+4)exp(-i PI) + (-1) exp(-2i (2) PI(2)/4) + | (-1)exp(-i 2PI) + (+3) exp(-2i (2) PI(3)/4) | (+3)exp(-i 3PI) exp(0) =(1); exp(-i PI)= (-1); exp(-i 2PI) = (1); exp(-i 3PI) = (-1) Donc S(2) = +2(1)+4(-1)-1(1)+3(-1) S(2) = +2 -4 -1 -3 '''S(2) = -6''' '''Pour k = 3;''' s[n] = [+2,+4,-1,+3] '''(k) s[n] exp(-2i (k) PI(n)/4)''' S(3) = (+2) exp(-2i (3) PI(0)/4) + | (+2)exp(0) + (+4) exp(-2i (3) PI(1)/4) + | (+4)exp(-i 3PI/2) + (-1) exp(-2i (3) PI(2)/4) + | (-1)exp(-i 3PI) + (+3) exp(-2i (3) PI(3)/4) | (+3)exp(-i 9PI/2) exp(0 =(1); exp(-i 3PI/2) = (i); exp(-i 3PI)= (-1); exp(-i 9PI/2) = (-i) Donc S(3) = +2(1)+4(i)-1(-1)+3(-i) S(3) = +2 +4i +1 -3i '''S(3) = 3 +i''' La TFD complète : Pour : s[n] = [+2,+4,-1,+3] Nous avons obtenu : S(0) = +8 S(1) = +3 - i S(2) = -6 S(3) = +3 + i Avec les matrices : S = DFT4 * s : +8.000+0.000i +3.000-1.000i -6.000+0.000i +3.000+1.000i {{AutoCat}} inmzkjie4aqxd3g9i271zrw720mjihd 774191 774190 2026-10-05T08:25:30Z Xhungab 23827 774191 wikitext text/x-wiki [[Catégorie:Mathc complexes (livre)]] [[Mathc complexes/a277#Les fichiers principaux :| Application]] Soit le signal : s[n] = [+2,+4,-1,+3] Calculons S(k), la transformée de Fourier discrète : Nous avons '''la transformée de Fourier discrète (TFD)''': _ N-1 \ S(k) = | s[n] exp(-2ikPI n/N) pour 0 <= k < N /_n=0 Avec N = 4 _ 3 \ S(k) = | s[n] exp(-2 i k PI n/4) /_n=0 '''Pour k = 0;''' s[n] = [+2,+4,-1,+3] '''(k) s[n] exp(-2i (k) PI(n)/4)''' S(0) = (+2) exp(-2i (0) PI(0)/4) + | (+2)exp(0) + (+4) exp(-2i (0) PI(1)/4) + | (+4)exp(0) + (-1) exp(-2i (0) PI(2)/4) + | (-1)exp(0) + (+3) exp(-2i (0) PI(3)/4) | (+3)exp(0) '''[[Mathc complexes/0a4|exp(0)=(+1)]]''' Donc S(0) = +2(+1)+4(+1)-1(+1)+3(+1) S(0) = +2 +4 -1 +3 '''S(0) = 8''' '''Pour k = 1;''' s[n] = [+2,+4,-1,+3] '''(k) s[n] exp(-2i (k) PI(n)/4)''' S(1) = (+2) exp(-2i (1) PI(0)/4) + | (+2)exp(0) + (+4) exp(-2i (1) PI(1)/4) + | (+4)exp(-i PI/2) + (-1) exp(-2i (1) PI(2)/4) + | (-1)exp(-i PI) + (+3) exp(-2i (1) PI(3)/4) | (+3)exp(-i 3PI/2) exp(0) =(1); exp(-i PI/2) = (-i); exp(-i PI) = (-1); exp(-i 3PI/2) = (i); Donc S(1) = +2(1)+4(-i)-1(-1)+3(i) S(1) = +2 -4i +1 +3i '''S(1) = 3 - i''' '''Pour k = 2;''' s[n] = [+2,+4,-1,+3] '''(k) s[n] exp(-2i (k) PI(n)/4)''' S(2) = (+2) exp(-2i (2) PI(0)/4) + | (+2)exp(0) + (+4) exp(-2i (2) PI(1)/4) + | (+4)exp(-i PI) + (-1) exp(-2i (2) PI(2)/4) + | (-1)exp(-i 2PI) + (+3) exp(-2i (2) PI(3)/4) | (+3)exp(-i 3PI) exp(0) =(1); exp(-i PI)= (-1); exp(-i 2PI) = (1); exp(-i 3PI) = (-1) Donc S(2) = +2(1)+4(-1)-1(1)+3(-1) S(2) = +2 -4 -1 -3 '''S(2) = -6''' '''Pour k = 3;''' s[n] = [+2,+4,-1,+3] '''(k) s[n] exp(-2i (k) PI(n)/4)''' S(3) = (+2) exp(-2i (3) PI(0)/4) + | (+2)exp(0) + (+4) exp(-2i (3) PI(1)/4) + | (+4)exp(-i 3PI/2) + (-1) exp(-2i (3) PI(2)/4) + | (-1)exp(-i 3PI) + (+3) exp(-2i (3) PI(3)/4) | (+3)exp(-i 9PI/2) exp(0 =(1); exp(-i 3PI/2) = (i); exp(-i 3PI)= (-1); exp(-i 9PI/2) = (-i) Donc S(3) = +2(1)+4(i)-1(-1)+3(-i) S(3) = +2 +4i +1 -3i '''S(3) = 3 +i''' La TFD complète : Pour : s[n] = [+2,+4,-1,+3] Nous avons obtenu : S(0) = +8 S(1) = +3 - i S(2) = -6 S(3) = +3 + i Avec les matrices : S = DFT4 * s : +8.000+0.000i +3.000-1.000i -6.000+0.000i +3.000+1.000i {{AutoCat}} 421meyfla6f6onqt0vgap0gicheu4fr Fonctionnement d'un ordinateur/Les processeurs superscalaires out-of-order 0 84543 774136 773842 2026-10-04T14:02:20Z Mewtow 31375 /* La superscalarité à schedulers décentralisés */ 774136 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. ==L'exemple du PA-RISC PA 8000== Avant de commencer, nous allons voir un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Passons maintenant à la superscalarité. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : une réservée aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ==Les ''schedulers'' des processeurs superscalaires== Une subtilité très importante est que la superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Evimmdent, cela implique que des µops aient été accumulées dans le ''sheduler''. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, *en moyenne*, la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order''. Par exemple, il devient rentable de dupliquer les unités mémoire. Vu que les accès mémoire ne sont plus émis dans l'ordre, autant en profiter pour en émettre plusieurs à la fois. Ilo en est de même avec les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule''. Par exemple, prenons un processeur triple émission avec deux stations de réservation : une pour les µops entières/mémoire, et une autre pour les µops flottantes. L'unité d'émission peut ''dispatch'' maximum trois µops par cycle : soit trois µops entières/mémoire, soit trois flottantes, soit un mix de trois µops différentes. Les stations de réservation, quant à elles, peuvent chacune ''schedule'' 3 µops par cycle, vers trois ALUs entières pour la station de réservation entière/mémoire, vers trois FPU pour la station de réservation flottante. De quoi ''dispatch'' trois µops, mais d'en ''schedule'' six, à chaque cycle. Et cet exemple ne sort pas de nulle part : les CPU x86 d'Intel et AMD sont globalement dans ce cas, même si les chiffres varient d'une microarchitecture à l'autre. L'exemple précédent colle assez bien à la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. La seule différence est que le ''scheduler'' entier alimentait trois ALU entières et trois AGU (calculs d'adresse). Il avait trois ports, chaque port était relié à une ALU et une AGU. Il gérait donc à la fois les opérations entières et les µops mémoire. [[File:AMD K7.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] Un autre exemple est celui de l'alpha 21264, qui pouvait ''dispatch'' 4 µops et en ''schedule'' 6. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 pouvait ''schedule'' 6 µops pour 4 µops au ''dispatch''. Il décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : un pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait accepter 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Ils peuvent chacun mémoriser 16 µops. Pour ce qui est du ''schedule'', maintenant. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''sheduler'' mémoire ne pouvait émettre qu'une µop mémoire à la fois, sur une AGU de calcul d'adresse unique. Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/divison flottante et qu'un branchement conditionnel flottant, et la file de µops mémoire ne pouvait émettre qu'une µop mémoire à la fois. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'avoir une '''émission multiple des accès mémoire''', à savoir d'émettre plusieurs micro-opérations mémoire en même temps. Les processeurs superscalaires modernes sont capables d'émettre plusieurs lectures/écritures simultanément. Par exemple, ils peuvent émettre une lecture en même temps qu'une écriture, ou plusieurs lectures, ou plusieurs écritures. Il faut noter que selon le processeur, il peut y avoir des restrictions quant aux accès mémoire émis en même temps. Par exemple, certains processeurs peuvent émettre une lecture avec une écriture en même temps, mais pas deux lectures ni deux écritures. Ou encore, ils peuvent émettre deux lectures, une lecture et une écriture, mais pas deux écritures en même temps. Dans la majorité des cas, les processeurs ne permettent pas d'émettre deux écritures en même temps, alors qu'ils supportent plusieurs lectures. Il faut dire que les lectures sont plus fréquentes que les écritures. Les processeurs qui autorisent toutes les combinaisons de lecture/écriture possibles, sont rares. L'émission multiple des accès mémoire s'implémente dans l'unité mémoire. Pour rappel, l'unité mémoire s'interpose entre le cache L1 et le reste du pipeline. Dans le cas le plus simple, elle incorpore une ''load/store queue'' et des unités de calcul d'adresse. L'émission multiple des accès mémoire demande de rendre la ''Load-store queue'' multi-ports, afin de gérer plusieurs micro-opérations mémoire simultanées. Il faut aussi dupliquer les unités de calcul d'adresse reliées à la LSQ, chaque port d'émission mémoire devant avoir sa propre unité de calcul d'adresse. : Dans ce qui suit, nous parlerons d'AGU (''Adress Generation Unit'') pour désigner les unités de calcul d'adresse, et de LSQ pour designer la ''Load-store queue''. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Dupliquer les ports de la LSQ et les AGU est nécessaire. Mais qu'en est-il du cache ? La réponse varie suivant le processeur. Il y a deux grandes méthodes pour implémenter l'émission multiple des accès mémoire. La première méthode ajoute des ports de lecture/écriture au cache de données, pour faire des lectures/écritures simultanées. Il faut alors dupliquer des unités de calcul, rajouter les ports sur le cache, mais aussi rajouter des ports sur la ''load/store queue'', pour qu'elle échange des données/commandes mémoire avec le cache. Le cout en circuits est alors conséquent La seconde duplique les unités de calcul, mais pas les ports d'accès au cache. Les accès mémoire sont alors accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K7 K8, à savoir les anciens AMD Athlon et AMD Athlon 64, qui pouvaient calculer trois adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Le cout en circuit est modéré : les unités de calcul coutent très peu, les ports de la ''load/store queue'' coutent déjà un peu plus. Quelques processeurs utilisaient des solutions intermédiaires. Un processeur utilisant cette technique est, par exemple, capable d'émettre quatre µops mémoire en même temps, mais avec seulement deux ports sur le cache. Un exemple classique étant les Pentium 1 et 2 d'Intel, dont nous parlerons dans le chapitre suivant. ===L'émission séparée des LOAD et STORE=== De rares processeurs ont des ports d'émission qui peuvent faire indifféremment lecture comme écritures. Mais les processeurs modernes préfèrent séparer ports d'émission pour les lectures et les écritures. Un exemple est celui des processeurs Intel de micro-architecture Nehalem, qui avaient un port de lecture et un port d'écriture tous deux reliés à la ''Load-store queue'', chacun avec leur propre unité de calcul d'adresse. Comme autre exemple, les processeurs skylake ont une LSQ avec deux ports de lecture et un port d'écriture, ce qui permet d'émettre deux lectures en même temps qu'une écriture. Dans cette section, nous allons étudier le cas avec des ports séparés pour les lectures et écritures. En général, les processeurs ont un peu plus de ports de lectures que d'écritures. Une raison est qu'un programme a, en moyenne, un tout petit peu plus de lectures que d'écritures. Mais la différence n'est pas énorme, c'est un léger déséquilibre. Une seconde raison est que l'exécution dans le désordre marche bien mieux quand les lectures ont lieu le plus tôt possible. Donc mieux vaut privilégier les lectures à l'émission, pour qu'elles se fassent un peu en avance. En pratique, la différence n'est pas très marquée. Il est courant d'avoir un port de lecture et un d'écriture, les CPU superscalaires très larges ont souvent deux ports de lecture et deux d'écriture. A la rigueur, quelques CPU ont trois ports de lecture et deux d'écriture. Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> q5s22hh6lgtn6bcrj52qis49tzetqt4 774137 774136 2026-10-04T14:04:40Z Mewtow 31375 /* La superscalarité à schedulers décentralisés */ 774137 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. ==L'exemple du PA-RISC PA 8000== Avant de commencer, nous allons voir un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Passons maintenant à la superscalarité. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : une réservée aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ==Les ''schedulers'' des processeurs superscalaires== Une subtilité très importante est que la superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Evimmdent, cela implique que des µops aient été accumulées dans le ''sheduler''. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, *en moyenne*, la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order''. Par exemple, il devient rentable de dupliquer les unités mémoire. Vu que les accès mémoire ne sont plus émis dans l'ordre, autant en profiter pour en émettre plusieurs à la fois. Ilo en est de même avec les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule''. Les processeurs quadruple émission historique avaient tendance à ''schedule'' 6 µops par cycle, réparties équitablement entre opérations entière, flottante et mémoire. En clair, elles pouvaient ''dispatch'' 4 µops et en ''schedule'' 6, dont 2 µops AGU, deux µops entières et 2 µops flottantes. Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 pouvait ''schedule'' 6 µops pour 4 µops au ''dispatch''. Il décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : un pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait accepter 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Ils peuvent chacun mémoriser 16 µops. Pour ce qui est du ''schedule'', maintenant. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''sheduler'' mémoire ne pouvait émettre qu'une µop mémoire à la fois, sur une AGU de calcul d'adresse unique. Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/divison flottante et qu'un branchement conditionnel flottant, et la file de µops mémoire ne pouvait émettre qu'une µop mémoire à la fois. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Par exemple, prenons un processeur triple émission avec deux stations de réservation : une pour les µops entières/mémoire, et une autre pour les µops flottantes. L'unité d'émission peut ''dispatch'' maximum trois µops par cycle : soit trois µops entières/mémoire, soit trois flottantes, soit un mix de trois µops différentes. Les stations de réservation, quant à elles, peuvent chacune ''schedule'' 3 µops par cycle, vers trois ALUs entières pour la station de réservation entière/mémoire, vers trois FPU pour la station de réservation flottante. De quoi ''dispatch'' trois µops, mais d'en ''schedule'' six, à chaque cycle. Et cet exemple ne sort pas de nulle part : les CPU x86 d'Intel et AMD sont globalement dans ce cas, même si les chiffres varient d'une microarchitecture à l'autre. L'exemple précédent colle assez bien à la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. La seule différence est que le ''scheduler'' entier alimentait trois ALU entières et trois AGU (calculs d'adresse). Il avait trois ports, chaque port était relié à une ALU et une AGU. Il gérait donc à la fois les opérations entières et les µops mémoire. [[File:AMD K7.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'avoir une '''émission multiple des accès mémoire''', à savoir d'émettre plusieurs micro-opérations mémoire en même temps. Les processeurs superscalaires modernes sont capables d'émettre plusieurs lectures/écritures simultanément. Par exemple, ils peuvent émettre une lecture en même temps qu'une écriture, ou plusieurs lectures, ou plusieurs écritures. Il faut noter que selon le processeur, il peut y avoir des restrictions quant aux accès mémoire émis en même temps. Par exemple, certains processeurs peuvent émettre une lecture avec une écriture en même temps, mais pas deux lectures ni deux écritures. Ou encore, ils peuvent émettre deux lectures, une lecture et une écriture, mais pas deux écritures en même temps. Dans la majorité des cas, les processeurs ne permettent pas d'émettre deux écritures en même temps, alors qu'ils supportent plusieurs lectures. Il faut dire que les lectures sont plus fréquentes que les écritures. Les processeurs qui autorisent toutes les combinaisons de lecture/écriture possibles, sont rares. L'émission multiple des accès mémoire s'implémente dans l'unité mémoire. Pour rappel, l'unité mémoire s'interpose entre le cache L1 et le reste du pipeline. Dans le cas le plus simple, elle incorpore une ''load/store queue'' et des unités de calcul d'adresse. L'émission multiple des accès mémoire demande de rendre la ''Load-store queue'' multi-ports, afin de gérer plusieurs micro-opérations mémoire simultanées. Il faut aussi dupliquer les unités de calcul d'adresse reliées à la LSQ, chaque port d'émission mémoire devant avoir sa propre unité de calcul d'adresse. : Dans ce qui suit, nous parlerons d'AGU (''Adress Generation Unit'') pour désigner les unités de calcul d'adresse, et de LSQ pour designer la ''Load-store queue''. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Dupliquer les ports de la LSQ et les AGU est nécessaire. Mais qu'en est-il du cache ? La réponse varie suivant le processeur. Il y a deux grandes méthodes pour implémenter l'émission multiple des accès mémoire. La première méthode ajoute des ports de lecture/écriture au cache de données, pour faire des lectures/écritures simultanées. Il faut alors dupliquer des unités de calcul, rajouter les ports sur le cache, mais aussi rajouter des ports sur la ''load/store queue'', pour qu'elle échange des données/commandes mémoire avec le cache. Le cout en circuits est alors conséquent La seconde duplique les unités de calcul, mais pas les ports d'accès au cache. Les accès mémoire sont alors accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K7 K8, à savoir les anciens AMD Athlon et AMD Athlon 64, qui pouvaient calculer trois adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Le cout en circuit est modéré : les unités de calcul coutent très peu, les ports de la ''load/store queue'' coutent déjà un peu plus. Quelques processeurs utilisaient des solutions intermédiaires. Un processeur utilisant cette technique est, par exemple, capable d'émettre quatre µops mémoire en même temps, mais avec seulement deux ports sur le cache. Un exemple classique étant les Pentium 1 et 2 d'Intel, dont nous parlerons dans le chapitre suivant. ===L'émission séparée des LOAD et STORE=== De rares processeurs ont des ports d'émission qui peuvent faire indifféremment lecture comme écritures. Mais les processeurs modernes préfèrent séparer ports d'émission pour les lectures et les écritures. Un exemple est celui des processeurs Intel de micro-architecture Nehalem, qui avaient un port de lecture et un port d'écriture tous deux reliés à la ''Load-store queue'', chacun avec leur propre unité de calcul d'adresse. Comme autre exemple, les processeurs skylake ont une LSQ avec deux ports de lecture et un port d'écriture, ce qui permet d'émettre deux lectures en même temps qu'une écriture. Dans cette section, nous allons étudier le cas avec des ports séparés pour les lectures et écritures. En général, les processeurs ont un peu plus de ports de lectures que d'écritures. Une raison est qu'un programme a, en moyenne, un tout petit peu plus de lectures que d'écritures. Mais la différence n'est pas énorme, c'est un léger déséquilibre. Une seconde raison est que l'exécution dans le désordre marche bien mieux quand les lectures ont lieu le plus tôt possible. Donc mieux vaut privilégier les lectures à l'émission, pour qu'elles se fassent un peu en avance. En pratique, la différence n'est pas très marquée. Il est courant d'avoir un port de lecture et un d'écriture, les CPU superscalaires très larges ont souvent deux ports de lecture et deux d'écriture. A la rigueur, quelques CPU ont trois ports de lecture et deux d'écriture. Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> 5x1cjlkpt0vzm6d2py9oy56yba0karh 774140 774137 2026-10-04T14:18:42Z Mewtow 31375 /* La superscalarité à schedulers décentralisés */ 774140 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. ==L'exemple du PA-RISC PA 8000== Avant de commencer, nous allons voir un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Passons maintenant à la superscalarité. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : une réservée aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ==Les ''schedulers'' des processeurs superscalaires== Une subtilité très importante est que la superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Evimmdent, cela implique que des µops aient été accumulées dans le ''sheduler''. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, *en moyenne*, la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order''. Par exemple, il devient rentable de dupliquer les unités mémoire. Vu que les accès mémoire ne sont plus émis dans l'ordre, autant en profiter pour en émettre plusieurs à la fois. Ilo en est de même avec les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:AMD K7.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'avoir une '''émission multiple des accès mémoire''', à savoir d'émettre plusieurs micro-opérations mémoire en même temps. Les processeurs superscalaires modernes sont capables d'émettre plusieurs lectures/écritures simultanément. Par exemple, ils peuvent émettre une lecture en même temps qu'une écriture, ou plusieurs lectures, ou plusieurs écritures. Il faut noter que selon le processeur, il peut y avoir des restrictions quant aux accès mémoire émis en même temps. Par exemple, certains processeurs peuvent émettre une lecture avec une écriture en même temps, mais pas deux lectures ni deux écritures. Ou encore, ils peuvent émettre deux lectures, une lecture et une écriture, mais pas deux écritures en même temps. Dans la majorité des cas, les processeurs ne permettent pas d'émettre deux écritures en même temps, alors qu'ils supportent plusieurs lectures. Il faut dire que les lectures sont plus fréquentes que les écritures. Les processeurs qui autorisent toutes les combinaisons de lecture/écriture possibles, sont rares. L'émission multiple des accès mémoire s'implémente dans l'unité mémoire. Pour rappel, l'unité mémoire s'interpose entre le cache L1 et le reste du pipeline. Dans le cas le plus simple, elle incorpore une ''load/store queue'' et des unités de calcul d'adresse. L'émission multiple des accès mémoire demande de rendre la ''Load-store queue'' multi-ports, afin de gérer plusieurs micro-opérations mémoire simultanées. Il faut aussi dupliquer les unités de calcul d'adresse reliées à la LSQ, chaque port d'émission mémoire devant avoir sa propre unité de calcul d'adresse. : Dans ce qui suit, nous parlerons d'AGU (''Adress Generation Unit'') pour désigner les unités de calcul d'adresse, et de LSQ pour designer la ''Load-store queue''. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Dupliquer les ports de la LSQ et les AGU est nécessaire. Mais qu'en est-il du cache ? La réponse varie suivant le processeur. Il y a deux grandes méthodes pour implémenter l'émission multiple des accès mémoire. La première méthode ajoute des ports de lecture/écriture au cache de données, pour faire des lectures/écritures simultanées. Il faut alors dupliquer des unités de calcul, rajouter les ports sur le cache, mais aussi rajouter des ports sur la ''load/store queue'', pour qu'elle échange des données/commandes mémoire avec le cache. Le cout en circuits est alors conséquent La seconde duplique les unités de calcul, mais pas les ports d'accès au cache. Les accès mémoire sont alors accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K7 K8, à savoir les anciens AMD Athlon et AMD Athlon 64, qui pouvaient calculer trois adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Le cout en circuit est modéré : les unités de calcul coutent très peu, les ports de la ''load/store queue'' coutent déjà un peu plus. Quelques processeurs utilisaient des solutions intermédiaires. Un processeur utilisant cette technique est, par exemple, capable d'émettre quatre µops mémoire en même temps, mais avec seulement deux ports sur le cache. Un exemple classique étant les Pentium 1 et 2 d'Intel, dont nous parlerons dans le chapitre suivant. ===L'émission séparée des LOAD et STORE=== De rares processeurs ont des ports d'émission qui peuvent faire indifféremment lecture comme écritures. Mais les processeurs modernes préfèrent séparer ports d'émission pour les lectures et les écritures. Un exemple est celui des processeurs Intel de micro-architecture Nehalem, qui avaient un port de lecture et un port d'écriture tous deux reliés à la ''Load-store queue'', chacun avec leur propre unité de calcul d'adresse. Comme autre exemple, les processeurs skylake ont une LSQ avec deux ports de lecture et un port d'écriture, ce qui permet d'émettre deux lectures en même temps qu'une écriture. Dans cette section, nous allons étudier le cas avec des ports séparés pour les lectures et écritures. En général, les processeurs ont un peu plus de ports de lectures que d'écritures. Une raison est qu'un programme a, en moyenne, un tout petit peu plus de lectures que d'écritures. Mais la différence n'est pas énorme, c'est un léger déséquilibre. Une seconde raison est que l'exécution dans le désordre marche bien mieux quand les lectures ont lieu le plus tôt possible. Donc mieux vaut privilégier les lectures à l'émission, pour qu'elles se fassent un peu en avance. En pratique, la différence n'est pas très marquée. Il est courant d'avoir un port de lecture et un d'écriture, les CPU superscalaires très larges ont souvent deux ports de lecture et deux d'écriture. A la rigueur, quelques CPU ont trois ports de lecture et deux d'écriture. Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> cabhad8hxx9mq345x5vdvlrhjhnci2k 774141 774140 2026-10-04T14:24:17Z Mewtow 31375 /* La superscalarité à schedulers totalement décentralisés */ 774141 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. ==L'exemple du PA-RISC PA 8000== Avant de commencer, nous allons voir un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Passons maintenant à la superscalarité. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : une réservée aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ==Les ''schedulers'' des processeurs superscalaires== Une subtilité très importante est que la superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Evimmdent, cela implique que des µops aient été accumulées dans le ''sheduler''. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, *en moyenne*, la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order''. Par exemple, il devient rentable de dupliquer les unités mémoire. Vu que les accès mémoire ne sont plus émis dans l'ordre, autant en profiter pour en émettre plusieurs à la fois. Ilo en est de même avec les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:AMD K7.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'avoir une '''émission multiple des accès mémoire''', à savoir d'émettre plusieurs micro-opérations mémoire en même temps. Les processeurs superscalaires modernes sont capables d'émettre plusieurs lectures/écritures simultanément. Par exemple, ils peuvent émettre une lecture en même temps qu'une écriture, ou plusieurs lectures, ou plusieurs écritures. Il faut noter que selon le processeur, il peut y avoir des restrictions quant aux accès mémoire émis en même temps. Par exemple, certains processeurs peuvent émettre une lecture avec une écriture en même temps, mais pas deux lectures ni deux écritures. Ou encore, ils peuvent émettre deux lectures, une lecture et une écriture, mais pas deux écritures en même temps. Dans la majorité des cas, les processeurs ne permettent pas d'émettre deux écritures en même temps, alors qu'ils supportent plusieurs lectures. Il faut dire que les lectures sont plus fréquentes que les écritures. Les processeurs qui autorisent toutes les combinaisons de lecture/écriture possibles, sont rares. L'émission multiple des accès mémoire s'implémente dans l'unité mémoire. Pour rappel, l'unité mémoire s'interpose entre le cache L1 et le reste du pipeline. Dans le cas le plus simple, elle incorpore une ''load/store queue'' et des unités de calcul d'adresse. L'émission multiple des accès mémoire demande de rendre la ''Load-store queue'' multi-ports, afin de gérer plusieurs micro-opérations mémoire simultanées. Il faut aussi dupliquer les unités de calcul d'adresse reliées à la LSQ, chaque port d'émission mémoire devant avoir sa propre unité de calcul d'adresse. : Dans ce qui suit, nous parlerons d'AGU (''Adress Generation Unit'') pour désigner les unités de calcul d'adresse, et de LSQ pour designer la ''Load-store queue''. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Dupliquer les ports de la LSQ et les AGU est nécessaire. Mais qu'en est-il du cache ? La réponse varie suivant le processeur. Il y a deux grandes méthodes pour implémenter l'émission multiple des accès mémoire. La première méthode ajoute des ports de lecture/écriture au cache de données, pour faire des lectures/écritures simultanées. Il faut alors dupliquer des unités de calcul, rajouter les ports sur le cache, mais aussi rajouter des ports sur la ''load/store queue'', pour qu'elle échange des données/commandes mémoire avec le cache. Le cout en circuits est alors conséquent La seconde duplique les unités de calcul, mais pas les ports d'accès au cache. Les accès mémoire sont alors accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K7 K8, à savoir les anciens AMD Athlon et AMD Athlon 64, qui pouvaient calculer trois adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Le cout en circuit est modéré : les unités de calcul coutent très peu, les ports de la ''load/store queue'' coutent déjà un peu plus. Quelques processeurs utilisaient des solutions intermédiaires. Un processeur utilisant cette technique est, par exemple, capable d'émettre quatre µops mémoire en même temps, mais avec seulement deux ports sur le cache. Un exemple classique étant les Pentium 1 et 2 d'Intel, dont nous parlerons dans le chapitre suivant. ===L'émission séparée des LOAD et STORE=== De rares processeurs ont des ports d'émission qui peuvent faire indifféremment lecture comme écritures. Mais les processeurs modernes préfèrent séparer ports d'émission pour les lectures et les écritures. Un exemple est celui des processeurs Intel de micro-architecture Nehalem, qui avaient un port de lecture et un port d'écriture tous deux reliés à la ''Load-store queue'', chacun avec leur propre unité de calcul d'adresse. Comme autre exemple, les processeurs skylake ont une LSQ avec deux ports de lecture et un port d'écriture, ce qui permet d'émettre deux lectures en même temps qu'une écriture. Dans cette section, nous allons étudier le cas avec des ports séparés pour les lectures et écritures. En général, les processeurs ont un peu plus de ports de lectures que d'écritures. Une raison est qu'un programme a, en moyenne, un tout petit peu plus de lectures que d'écritures. Mais la différence n'est pas énorme, c'est un léger déséquilibre. Une seconde raison est que l'exécution dans le désordre marche bien mieux quand les lectures ont lieu le plus tôt possible. Donc mieux vaut privilégier les lectures à l'émission, pour qu'elles se fassent un peu en avance. En pratique, la différence n'est pas très marquée. Il est courant d'avoir un port de lecture et un d'écriture, les CPU superscalaires très larges ont souvent deux ports de lecture et deux d'écriture. A la rigueur, quelques CPU ont trois ports de lecture et deux d'écriture. Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> 21188e7pyuekxmjagths8ti73x4ip6w 774142 774141 2026-10-04T14:27:01Z Mewtow 31375 /* La superscalarité à schedulers totalement décentralisés */ 774142 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. ==L'exemple du PA-RISC PA 8000== Avant de commencer, nous allons voir un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Passons maintenant à la superscalarité. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : une réservée aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ==Les ''schedulers'' des processeurs superscalaires== Une subtilité très importante est que la superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Evimmdent, cela implique que des µops aient été accumulées dans le ''sheduler''. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, *en moyenne*, la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order''. Par exemple, il devient rentable de dupliquer les unités mémoire. Vu que les accès mémoire ne sont plus émis dans l'ordre, autant en profiter pour en émettre plusieurs à la fois. Ilo en est de même avec les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:AMD K7.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). Le processeur était superscalaire, ce qui explique la présence de 4 décodeurs, mais laissons cela pour un prochain chapitre. [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'avoir une '''émission multiple des accès mémoire''', à savoir d'émettre plusieurs micro-opérations mémoire en même temps. Les processeurs superscalaires modernes sont capables d'émettre plusieurs lectures/écritures simultanément. Par exemple, ils peuvent émettre une lecture en même temps qu'une écriture, ou plusieurs lectures, ou plusieurs écritures. Il faut noter que selon le processeur, il peut y avoir des restrictions quant aux accès mémoire émis en même temps. Par exemple, certains processeurs peuvent émettre une lecture avec une écriture en même temps, mais pas deux lectures ni deux écritures. Ou encore, ils peuvent émettre deux lectures, une lecture et une écriture, mais pas deux écritures en même temps. Dans la majorité des cas, les processeurs ne permettent pas d'émettre deux écritures en même temps, alors qu'ils supportent plusieurs lectures. Il faut dire que les lectures sont plus fréquentes que les écritures. Les processeurs qui autorisent toutes les combinaisons de lecture/écriture possibles, sont rares. L'émission multiple des accès mémoire s'implémente dans l'unité mémoire. Pour rappel, l'unité mémoire s'interpose entre le cache L1 et le reste du pipeline. Dans le cas le plus simple, elle incorpore une ''load/store queue'' et des unités de calcul d'adresse. L'émission multiple des accès mémoire demande de rendre la ''Load-store queue'' multi-ports, afin de gérer plusieurs micro-opérations mémoire simultanées. Il faut aussi dupliquer les unités de calcul d'adresse reliées à la LSQ, chaque port d'émission mémoire devant avoir sa propre unité de calcul d'adresse. : Dans ce qui suit, nous parlerons d'AGU (''Adress Generation Unit'') pour désigner les unités de calcul d'adresse, et de LSQ pour designer la ''Load-store queue''. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Dupliquer les ports de la LSQ et les AGU est nécessaire. Mais qu'en est-il du cache ? La réponse varie suivant le processeur. Il y a deux grandes méthodes pour implémenter l'émission multiple des accès mémoire. La première méthode ajoute des ports de lecture/écriture au cache de données, pour faire des lectures/écritures simultanées. Il faut alors dupliquer des unités de calcul, rajouter les ports sur le cache, mais aussi rajouter des ports sur la ''load/store queue'', pour qu'elle échange des données/commandes mémoire avec le cache. Le cout en circuits est alors conséquent La seconde duplique les unités de calcul, mais pas les ports d'accès au cache. Les accès mémoire sont alors accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K7 K8, à savoir les anciens AMD Athlon et AMD Athlon 64, qui pouvaient calculer trois adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Le cout en circuit est modéré : les unités de calcul coutent très peu, les ports de la ''load/store queue'' coutent déjà un peu plus. Quelques processeurs utilisaient des solutions intermédiaires. Un processeur utilisant cette technique est, par exemple, capable d'émettre quatre µops mémoire en même temps, mais avec seulement deux ports sur le cache. Un exemple classique étant les Pentium 1 et 2 d'Intel, dont nous parlerons dans le chapitre suivant. ===L'émission séparée des LOAD et STORE=== De rares processeurs ont des ports d'émission qui peuvent faire indifféremment lecture comme écritures. Mais les processeurs modernes préfèrent séparer ports d'émission pour les lectures et les écritures. Un exemple est celui des processeurs Intel de micro-architecture Nehalem, qui avaient un port de lecture et un port d'écriture tous deux reliés à la ''Load-store queue'', chacun avec leur propre unité de calcul d'adresse. Comme autre exemple, les processeurs skylake ont une LSQ avec deux ports de lecture et un port d'écriture, ce qui permet d'émettre deux lectures en même temps qu'une écriture. Dans cette section, nous allons étudier le cas avec des ports séparés pour les lectures et écritures. En général, les processeurs ont un peu plus de ports de lectures que d'écritures. Une raison est qu'un programme a, en moyenne, un tout petit peu plus de lectures que d'écritures. Mais la différence n'est pas énorme, c'est un léger déséquilibre. Une seconde raison est que l'exécution dans le désordre marche bien mieux quand les lectures ont lieu le plus tôt possible. Donc mieux vaut privilégier les lectures à l'émission, pour qu'elles se fassent un peu en avance. En pratique, la différence n'est pas très marquée. Il est courant d'avoir un port de lecture et un d'écriture, les CPU superscalaires très larges ont souvent deux ports de lecture et deux d'écriture. A la rigueur, quelques CPU ont trois ports de lecture et deux d'écriture. Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> slh2ytek39o9rojeexg8zemqoj8bpg9 774143 774142 2026-10-04T14:27:38Z Mewtow 31375 /* La superscalarité à schedulers totalement décentralisés */ 774143 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. ==L'exemple du PA-RISC PA 8000== Avant de commencer, nous allons voir un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Passons maintenant à la superscalarité. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : une réservée aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ==Les ''schedulers'' des processeurs superscalaires== Une subtilité très importante est que la superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Evimmdent, cela implique que des µops aient été accumulées dans le ''sheduler''. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, *en moyenne*, la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order''. Par exemple, il devient rentable de dupliquer les unités mémoire. Vu que les accès mémoire ne sont plus émis dans l'ordre, autant en profiter pour en émettre plusieurs à la fois. Ilo en est de même avec les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:AMD K7.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'avoir une '''émission multiple des accès mémoire''', à savoir d'émettre plusieurs micro-opérations mémoire en même temps. Les processeurs superscalaires modernes sont capables d'émettre plusieurs lectures/écritures simultanément. Par exemple, ils peuvent émettre une lecture en même temps qu'une écriture, ou plusieurs lectures, ou plusieurs écritures. Il faut noter que selon le processeur, il peut y avoir des restrictions quant aux accès mémoire émis en même temps. Par exemple, certains processeurs peuvent émettre une lecture avec une écriture en même temps, mais pas deux lectures ni deux écritures. Ou encore, ils peuvent émettre deux lectures, une lecture et une écriture, mais pas deux écritures en même temps. Dans la majorité des cas, les processeurs ne permettent pas d'émettre deux écritures en même temps, alors qu'ils supportent plusieurs lectures. Il faut dire que les lectures sont plus fréquentes que les écritures. Les processeurs qui autorisent toutes les combinaisons de lecture/écriture possibles, sont rares. L'émission multiple des accès mémoire s'implémente dans l'unité mémoire. Pour rappel, l'unité mémoire s'interpose entre le cache L1 et le reste du pipeline. Dans le cas le plus simple, elle incorpore une ''load/store queue'' et des unités de calcul d'adresse. L'émission multiple des accès mémoire demande de rendre la ''Load-store queue'' multi-ports, afin de gérer plusieurs micro-opérations mémoire simultanées. Il faut aussi dupliquer les unités de calcul d'adresse reliées à la LSQ, chaque port d'émission mémoire devant avoir sa propre unité de calcul d'adresse. : Dans ce qui suit, nous parlerons d'AGU (''Adress Generation Unit'') pour désigner les unités de calcul d'adresse, et de LSQ pour designer la ''Load-store queue''. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Dupliquer les ports de la LSQ et les AGU est nécessaire. Mais qu'en est-il du cache ? La réponse varie suivant le processeur. Il y a deux grandes méthodes pour implémenter l'émission multiple des accès mémoire. La première méthode ajoute des ports de lecture/écriture au cache de données, pour faire des lectures/écritures simultanées. Il faut alors dupliquer des unités de calcul, rajouter les ports sur le cache, mais aussi rajouter des ports sur la ''load/store queue'', pour qu'elle échange des données/commandes mémoire avec le cache. Le cout en circuits est alors conséquent La seconde duplique les unités de calcul, mais pas les ports d'accès au cache. Les accès mémoire sont alors accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K7 K8, à savoir les anciens AMD Athlon et AMD Athlon 64, qui pouvaient calculer trois adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Le cout en circuit est modéré : les unités de calcul coutent très peu, les ports de la ''load/store queue'' coutent déjà un peu plus. Quelques processeurs utilisaient des solutions intermédiaires. Un processeur utilisant cette technique est, par exemple, capable d'émettre quatre µops mémoire en même temps, mais avec seulement deux ports sur le cache. Un exemple classique étant les Pentium 1 et 2 d'Intel, dont nous parlerons dans le chapitre suivant. ===L'émission séparée des LOAD et STORE=== De rares processeurs ont des ports d'émission qui peuvent faire indifféremment lecture comme écritures. Mais les processeurs modernes préfèrent séparer ports d'émission pour les lectures et les écritures. Un exemple est celui des processeurs Intel de micro-architecture Nehalem, qui avaient un port de lecture et un port d'écriture tous deux reliés à la ''Load-store queue'', chacun avec leur propre unité de calcul d'adresse. Comme autre exemple, les processeurs skylake ont une LSQ avec deux ports de lecture et un port d'écriture, ce qui permet d'émettre deux lectures en même temps qu'une écriture. Dans cette section, nous allons étudier le cas avec des ports séparés pour les lectures et écritures. En général, les processeurs ont un peu plus de ports de lectures que d'écritures. Une raison est qu'un programme a, en moyenne, un tout petit peu plus de lectures que d'écritures. Mais la différence n'est pas énorme, c'est un léger déséquilibre. Une seconde raison est que l'exécution dans le désordre marche bien mieux quand les lectures ont lieu le plus tôt possible. Donc mieux vaut privilégier les lectures à l'émission, pour qu'elles se fassent un peu en avance. En pratique, la différence n'est pas très marquée. Il est courant d'avoir un port de lecture et un d'écriture, les CPU superscalaires très larges ont souvent deux ports de lecture et deux d'écriture. A la rigueur, quelques CPU ont trois ports de lecture et deux d'écriture. Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> 21188e7pyuekxmjagths8ti73x4ip6w 774144 774143 2026-10-04T14:34:23Z Mewtow 31375 /* Les schedulers des processeurs superscalaires */ 774144 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. ==L'exemple du PA-RISC PA 8000== Avant de commencer, nous allons voir un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Passons maintenant à la superscalarité. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : une réservée aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ==Les ''schedulers'' des processeurs superscalaires== La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Evimmdent, cela implique que des µops aient été accumulées dans le ''sheduler''. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order''. Par exemple, il devient rentable de dupliquer les unités mémoire. Vu que les accès mémoire ne sont plus émis dans l'ordre, autant en profiter pour en émettre plusieurs à la fois. Ilo en est de même avec les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:AMD K7.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'avoir une '''émission multiple des accès mémoire''', à savoir d'émettre plusieurs micro-opérations mémoire en même temps. Les processeurs superscalaires modernes sont capables d'émettre plusieurs lectures/écritures simultanément. Par exemple, ils peuvent émettre une lecture en même temps qu'une écriture, ou plusieurs lectures, ou plusieurs écritures. Il faut noter que selon le processeur, il peut y avoir des restrictions quant aux accès mémoire émis en même temps. Par exemple, certains processeurs peuvent émettre une lecture avec une écriture en même temps, mais pas deux lectures ni deux écritures. Ou encore, ils peuvent émettre deux lectures, une lecture et une écriture, mais pas deux écritures en même temps. Dans la majorité des cas, les processeurs ne permettent pas d'émettre deux écritures en même temps, alors qu'ils supportent plusieurs lectures. Il faut dire que les lectures sont plus fréquentes que les écritures. Les processeurs qui autorisent toutes les combinaisons de lecture/écriture possibles, sont rares. L'émission multiple des accès mémoire s'implémente dans l'unité mémoire. Pour rappel, l'unité mémoire s'interpose entre le cache L1 et le reste du pipeline. Dans le cas le plus simple, elle incorpore une ''load/store queue'' et des unités de calcul d'adresse. L'émission multiple des accès mémoire demande de rendre la ''Load-store queue'' multi-ports, afin de gérer plusieurs micro-opérations mémoire simultanées. Il faut aussi dupliquer les unités de calcul d'adresse reliées à la LSQ, chaque port d'émission mémoire devant avoir sa propre unité de calcul d'adresse. : Dans ce qui suit, nous parlerons d'AGU (''Adress Generation Unit'') pour désigner les unités de calcul d'adresse, et de LSQ pour designer la ''Load-store queue''. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Dupliquer les ports de la LSQ et les AGU est nécessaire. Mais qu'en est-il du cache ? La réponse varie suivant le processeur. Il y a deux grandes méthodes pour implémenter l'émission multiple des accès mémoire. La première méthode ajoute des ports de lecture/écriture au cache de données, pour faire des lectures/écritures simultanées. Il faut alors dupliquer des unités de calcul, rajouter les ports sur le cache, mais aussi rajouter des ports sur la ''load/store queue'', pour qu'elle échange des données/commandes mémoire avec le cache. Le cout en circuits est alors conséquent La seconde duplique les unités de calcul, mais pas les ports d'accès au cache. Les accès mémoire sont alors accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K7 K8, à savoir les anciens AMD Athlon et AMD Athlon 64, qui pouvaient calculer trois adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Le cout en circuit est modéré : les unités de calcul coutent très peu, les ports de la ''load/store queue'' coutent déjà un peu plus. Quelques processeurs utilisaient des solutions intermédiaires. Un processeur utilisant cette technique est, par exemple, capable d'émettre quatre µops mémoire en même temps, mais avec seulement deux ports sur le cache. Un exemple classique étant les Pentium 1 et 2 d'Intel, dont nous parlerons dans le chapitre suivant. ===L'émission séparée des LOAD et STORE=== De rares processeurs ont des ports d'émission qui peuvent faire indifféremment lecture comme écritures. Mais les processeurs modernes préfèrent séparer ports d'émission pour les lectures et les écritures. Un exemple est celui des processeurs Intel de micro-architecture Nehalem, qui avaient un port de lecture et un port d'écriture tous deux reliés à la ''Load-store queue'', chacun avec leur propre unité de calcul d'adresse. Comme autre exemple, les processeurs skylake ont une LSQ avec deux ports de lecture et un port d'écriture, ce qui permet d'émettre deux lectures en même temps qu'une écriture. Dans cette section, nous allons étudier le cas avec des ports séparés pour les lectures et écritures. En général, les processeurs ont un peu plus de ports de lectures que d'écritures. Une raison est qu'un programme a, en moyenne, un tout petit peu plus de lectures que d'écritures. Mais la différence n'est pas énorme, c'est un léger déséquilibre. Une seconde raison est que l'exécution dans le désordre marche bien mieux quand les lectures ont lieu le plus tôt possible. Donc mieux vaut privilégier les lectures à l'émission, pour qu'elles se fassent un peu en avance. En pratique, la différence n'est pas très marquée. Il est courant d'avoir un port de lecture et un d'écriture, les CPU superscalaires très larges ont souvent deux ports de lecture et deux d'écriture. A la rigueur, quelques CPU ont trois ports de lecture et deux d'écriture. Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> b3czz22j8ar8whea93jjyre1t8pnjuv 774145 774144 2026-10-04T14:42:13Z Mewtow 31375 774145 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:AMD K7.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'avoir une '''émission multiple des accès mémoire''', à savoir d'émettre plusieurs micro-opérations mémoire en même temps. Les processeurs superscalaires modernes sont capables d'émettre plusieurs lectures/écritures simultanément. Par exemple, ils peuvent émettre une lecture en même temps qu'une écriture, ou plusieurs lectures, ou plusieurs écritures. Il faut noter que selon le processeur, il peut y avoir des restrictions quant aux accès mémoire émis en même temps. Par exemple, certains processeurs peuvent émettre une lecture avec une écriture en même temps, mais pas deux lectures ni deux écritures. Ou encore, ils peuvent émettre deux lectures, une lecture et une écriture, mais pas deux écritures en même temps. Dans la majorité des cas, les processeurs ne permettent pas d'émettre deux écritures en même temps, alors qu'ils supportent plusieurs lectures. Il faut dire que les lectures sont plus fréquentes que les écritures. Les processeurs qui autorisent toutes les combinaisons de lecture/écriture possibles, sont rares. L'émission multiple des accès mémoire s'implémente dans l'unité mémoire. Pour rappel, l'unité mémoire s'interpose entre le cache L1 et le reste du pipeline. Dans le cas le plus simple, elle incorpore une ''load/store queue'' et des unités de calcul d'adresse. L'émission multiple des accès mémoire demande de rendre la ''Load-store queue'' multi-ports, afin de gérer plusieurs micro-opérations mémoire simultanées. Il faut aussi dupliquer les unités de calcul d'adresse reliées à la LSQ, chaque port d'émission mémoire devant avoir sa propre unité de calcul d'adresse. : Dans ce qui suit, nous parlerons d'AGU (''Adress Generation Unit'') pour désigner les unités de calcul d'adresse, et de LSQ pour designer la ''Load-store queue''. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Dupliquer les ports de la LSQ et les AGU est nécessaire. Mais qu'en est-il du cache ? La réponse varie suivant le processeur. Il y a deux grandes méthodes pour implémenter l'émission multiple des accès mémoire. La première méthode ajoute des ports de lecture/écriture au cache de données, pour faire des lectures/écritures simultanées. Il faut alors dupliquer des unités de calcul, rajouter les ports sur le cache, mais aussi rajouter des ports sur la ''load/store queue'', pour qu'elle échange des données/commandes mémoire avec le cache. Le cout en circuits est alors conséquent La seconde duplique les unités de calcul, mais pas les ports d'accès au cache. Les accès mémoire sont alors accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K7 K8, à savoir les anciens AMD Athlon et AMD Athlon 64, qui pouvaient calculer trois adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Le cout en circuit est modéré : les unités de calcul coutent très peu, les ports de la ''load/store queue'' coutent déjà un peu plus. Quelques processeurs utilisaient des solutions intermédiaires. Un processeur utilisant cette technique est, par exemple, capable d'émettre quatre µops mémoire en même temps, mais avec seulement deux ports sur le cache. Un exemple classique étant les Pentium 1 et 2 d'Intel, dont nous parlerons dans le chapitre suivant. ===L'émission séparée des LOAD et STORE=== De rares processeurs ont des ports d'émission qui peuvent faire indifféremment lecture comme écritures. Mais les processeurs modernes préfèrent séparer ports d'émission pour les lectures et les écritures. Un exemple est celui des processeurs Intel de micro-architecture Nehalem, qui avaient un port de lecture et un port d'écriture tous deux reliés à la ''Load-store queue'', chacun avec leur propre unité de calcul d'adresse. Comme autre exemple, les processeurs skylake ont une LSQ avec deux ports de lecture et un port d'écriture, ce qui permet d'émettre deux lectures en même temps qu'une écriture. Dans cette section, nous allons étudier le cas avec des ports séparés pour les lectures et écritures. En général, les processeurs ont un peu plus de ports de lectures que d'écritures. Une raison est qu'un programme a, en moyenne, un tout petit peu plus de lectures que d'écritures. Mais la différence n'est pas énorme, c'est un léger déséquilibre. Une seconde raison est que l'exécution dans le désordre marche bien mieux quand les lectures ont lieu le plus tôt possible. Donc mieux vaut privilégier les lectures à l'émission, pour qu'elles se fassent un peu en avance. En pratique, la différence n'est pas très marquée. Il est courant d'avoir un port de lecture et un d'écriture, les CPU superscalaires très larges ont souvent deux ports de lecture et deux d'écriture. A la rigueur, quelques CPU ont trois ports de lecture et deux d'écriture. Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> 2fi1lvykaftemldt54oilmztkef7yt4 774146 774145 2026-10-04T14:43:06Z Mewtow 31375 /* La superscalarité à schedulers décentralisés */ 774146 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'avoir une '''émission multiple des accès mémoire''', à savoir d'émettre plusieurs micro-opérations mémoire en même temps. Les processeurs superscalaires modernes sont capables d'émettre plusieurs lectures/écritures simultanément. Par exemple, ils peuvent émettre une lecture en même temps qu'une écriture, ou plusieurs lectures, ou plusieurs écritures. Il faut noter que selon le processeur, il peut y avoir des restrictions quant aux accès mémoire émis en même temps. Par exemple, certains processeurs peuvent émettre une lecture avec une écriture en même temps, mais pas deux lectures ni deux écritures. Ou encore, ils peuvent émettre deux lectures, une lecture et une écriture, mais pas deux écritures en même temps. Dans la majorité des cas, les processeurs ne permettent pas d'émettre deux écritures en même temps, alors qu'ils supportent plusieurs lectures. Il faut dire que les lectures sont plus fréquentes que les écritures. Les processeurs qui autorisent toutes les combinaisons de lecture/écriture possibles, sont rares. L'émission multiple des accès mémoire s'implémente dans l'unité mémoire. Pour rappel, l'unité mémoire s'interpose entre le cache L1 et le reste du pipeline. Dans le cas le plus simple, elle incorpore une ''load/store queue'' et des unités de calcul d'adresse. L'émission multiple des accès mémoire demande de rendre la ''Load-store queue'' multi-ports, afin de gérer plusieurs micro-opérations mémoire simultanées. Il faut aussi dupliquer les unités de calcul d'adresse reliées à la LSQ, chaque port d'émission mémoire devant avoir sa propre unité de calcul d'adresse. : Dans ce qui suit, nous parlerons d'AGU (''Adress Generation Unit'') pour désigner les unités de calcul d'adresse, et de LSQ pour designer la ''Load-store queue''. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Dupliquer les ports de la LSQ et les AGU est nécessaire. Mais qu'en est-il du cache ? La réponse varie suivant le processeur. Il y a deux grandes méthodes pour implémenter l'émission multiple des accès mémoire. La première méthode ajoute des ports de lecture/écriture au cache de données, pour faire des lectures/écritures simultanées. Il faut alors dupliquer des unités de calcul, rajouter les ports sur le cache, mais aussi rajouter des ports sur la ''load/store queue'', pour qu'elle échange des données/commandes mémoire avec le cache. Le cout en circuits est alors conséquent La seconde duplique les unités de calcul, mais pas les ports d'accès au cache. Les accès mémoire sont alors accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K7 K8, à savoir les anciens AMD Athlon et AMD Athlon 64, qui pouvaient calculer trois adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Le cout en circuit est modéré : les unités de calcul coutent très peu, les ports de la ''load/store queue'' coutent déjà un peu plus. Quelques processeurs utilisaient des solutions intermédiaires. Un processeur utilisant cette technique est, par exemple, capable d'émettre quatre µops mémoire en même temps, mais avec seulement deux ports sur le cache. Un exemple classique étant les Pentium 1 et 2 d'Intel, dont nous parlerons dans le chapitre suivant. ===L'émission séparée des LOAD et STORE=== De rares processeurs ont des ports d'émission qui peuvent faire indifféremment lecture comme écritures. Mais les processeurs modernes préfèrent séparer ports d'émission pour les lectures et les écritures. Un exemple est celui des processeurs Intel de micro-architecture Nehalem, qui avaient un port de lecture et un port d'écriture tous deux reliés à la ''Load-store queue'', chacun avec leur propre unité de calcul d'adresse. Comme autre exemple, les processeurs skylake ont une LSQ avec deux ports de lecture et un port d'écriture, ce qui permet d'émettre deux lectures en même temps qu'une écriture. Dans cette section, nous allons étudier le cas avec des ports séparés pour les lectures et écritures. En général, les processeurs ont un peu plus de ports de lectures que d'écritures. Une raison est qu'un programme a, en moyenne, un tout petit peu plus de lectures que d'écritures. Mais la différence n'est pas énorme, c'est un léger déséquilibre. Une seconde raison est que l'exécution dans le désordre marche bien mieux quand les lectures ont lieu le plus tôt possible. Donc mieux vaut privilégier les lectures à l'émission, pour qu'elles se fassent un peu en avance. En pratique, la différence n'est pas très marquée. Il est courant d'avoir un port de lecture et un d'écriture, les CPU superscalaires très larges ont souvent deux ports de lecture et deux d'écriture. A la rigueur, quelques CPU ont trois ports de lecture et deux d'écriture. Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> 1sayau8w1vn7rzspibjost9ogfiu5ot 774148 774146 2026-10-04T15:25:53Z Mewtow 31375 /* L'émission multiple des accès mémoire avec une LSQ */ 774148 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. [[File:AMD K5.jpg|centre|vignette|upright=3|AMD K5]] Le cas précédent est tout de même rare. Avoir plusieurs AGUs mais un cache simple port ne permet pas d'avoir des gains de performance dignes de ce nom. Les performances sont cependant légèrement augmentées, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Les autres combinaisons, avec deux lectures ou deux écritures, sont interdites. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre des calculs d'adresse pour deux STORE en même temps, ou deux calculs d'adresse pour deux LOAD. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Une telle dé-spécialisation des AGUs permet de calculer des adresses en avance. En pratique, cette optimisation n'est pas fréquente, la majorité des processeurs sépare bien les AGU avec une dédiée aux lectures et une autre dédiée aux écritures. La raison est que si cette organisation permet de calculer des adresses en avance, elle marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. Quelques processeurs utilisaient des solutions intermédiaires. Un processeur utilisant cette technique est, par exemple, capable d'émettre quatre µops AGU en même temps, mais avec seulement deux ports sur le cache. Un exemple classique étant les Pentium 1 et 2 d'Intel, dont nous parlerons dans le chapitre suivant. ===L'émission séparée des LOAD et STORE=== De rares processeurs ont des ports d'émission qui peuvent faire indifféremment lecture comme écritures. Mais les processeurs modernes préfèrent séparer ports d'émission pour les lectures et les écritures. Un exemple est celui des processeurs Intel de micro-architecture Nehalem, qui avaient un port de lecture et un port d'écriture tous deux reliés à la ''Load-store queue'', chacun avec leur propre unité de calcul d'adresse. Comme autre exemple, les processeurs skylake ont une LSQ avec deux ports de lecture et un port d'écriture, ce qui permet d'émettre deux lectures en même temps qu'une écriture. Dans cette section, nous allons étudier le cas avec des ports séparés pour les lectures et écritures. En général, les processeurs ont un peu plus de ports de lectures que d'écritures. Une raison est qu'un programme a, en moyenne, un tout petit peu plus de lectures que d'écritures. Mais la différence n'est pas énorme, c'est un léger déséquilibre. Une seconde raison est que l'exécution dans le désordre marche bien mieux quand les lectures ont lieu le plus tôt possible. Donc mieux vaut privilégier les lectures à l'émission, pour qu'elles se fassent un peu en avance. En pratique, la différence n'est pas très marquée. Il est courant d'avoir un port de lecture et un d'écriture, les CPU superscalaires très larges ont souvent deux ports de lecture et deux d'écriture. A la rigueur, quelques CPU ont trois ports de lecture et deux d'écriture. Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> m7zmc2iy9vzx7u7bmofycxzbqfpopjk 774149 774148 2026-10-04T15:27:29Z Mewtow 31375 /* L'émission multiple des accès mémoire avec une LSQ */ 774149 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. [[File:AMD K5.jpg|centre|vignette|upright=3|AMD K5]] Le cas précédent est tout de même rare. Avoir plusieurs AGUs mais un cache simple port ne permet pas d'avoir des gains de performance dignes de ce nom. Les performances sont cependant légèrement augmentées, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Les autres combinaisons, avec deux lectures ou deux écritures, sont interdites. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre des calculs d'adresse pour deux STORE en même temps, ou deux calculs d'adresse pour deux LOAD. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Une telle dé-spécialisation des AGUs permet de calculer des adresses en avance, ce qui aide la désambiguïsation mémoire à faire son travail. En pratique, cette optimisation n'est pas fréquente, la majorité des processeurs x86 sépare bien les AGU avec une dédiée aux lectures et une autre dédiée aux écritures. Quelques processeurs l'ont utilisée, mais ce ne sont pas la majorité. La raison est que si cette organisation permet de calculer des adresses en avance, elle marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. Les processeurs superscalaires tendent à avoir des '''AGUs spécialisées''', avec des AGU LOAD qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des AGU STORE qui font les calculs d'adresse pour les écritures. Quelques processeurs utilisaient des solutions intermédiaires. Un processeur utilisant cette technique est, par exemple, capable d'émettre quatre µops AGU en même temps, mais avec seulement deux ports sur le cache. Un exemple classique étant les Pentium 1 et 2 d'Intel, dont nous parlerons dans le chapitre suivant. ===L'émission séparée des LOAD et STORE=== De rares processeurs ont des ports d'émission qui peuvent faire indifféremment lecture comme écritures. Mais les processeurs modernes préfèrent séparer ports d'émission pour les lectures et les écritures. Un exemple est celui des processeurs Intel de micro-architecture Nehalem, qui avaient un port de lecture et un port d'écriture tous deux reliés à la ''Load-store queue'', chacun avec leur propre unité de calcul d'adresse. Comme autre exemple, les processeurs skylake ont une LSQ avec deux ports de lecture et un port d'écriture, ce qui permet d'émettre deux lectures en même temps qu'une écriture. Dans cette section, nous allons étudier le cas avec des ports séparés pour les lectures et écritures. En général, les processeurs ont un peu plus de ports de lectures que d'écritures. Une raison est qu'un programme a, en moyenne, un tout petit peu plus de lectures que d'écritures. Mais la différence n'est pas énorme, c'est un léger déséquilibre. Une seconde raison est que l'exécution dans le désordre marche bien mieux quand les lectures ont lieu le plus tôt possible. Donc mieux vaut privilégier les lectures à l'émission, pour qu'elles se fassent un peu en avance. En pratique, la différence n'est pas très marquée. Il est courant d'avoir un port de lecture et un d'écriture, les CPU superscalaires très larges ont souvent deux ports de lecture et deux d'écriture. A la rigueur, quelques CPU ont trois ports de lecture et deux d'écriture. Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> h5lum5y9uobqpg9qsbt6ye3yfjwg6dg 774150 774149 2026-10-04T15:38:05Z Mewtow 31375 /* L'émission multiple des accès mémoire avec une LSQ */ 774150 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. [[File:AMD K5.jpg|centre|vignette|upright=3|AMD K5]] Le cas précédent est tout de même rare. Avoir plusieurs AGUs mais un cache simple port ne permet pas d'avoir des gains de performance dignes de ce nom. Les performances sont cependant légèrement augmentées, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Les autres combinaisons, avec deux lectures ou deux écritures, sont interdites. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre des calculs d'adresse pour deux STORE en même temps, ou deux calculs d'adresse pour deux LOAD. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Une telle dé-spécialisation des AGUs permet de calculer des adresses en avance, ce qui aide la désambiguïsation mémoire à faire son travail. En pratique, cette optimisation n'est pas fréquente, la majorité des processeurs x86 sépare bien les AGU avec une dédiée aux lectures et une autre dédiée aux écritures. Quelques processeurs l'ont utilisée, mais ce ne sont pas la majorité. La raison est que si cette organisation permet de calculer des adresses en avance, elle marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. Les processeurs superscalaires tendent à avoir des '''AGUs spécialisées''', avec des AGU LOAD qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des AGU STORE qui font les calculs d'adresse pour les écritures. ===L'émission séparée des LOAD et STORE=== De rares processeurs ont des ports d'émission qui peuvent faire indifféremment lecture comme écritures. Mais les processeurs modernes préfèrent séparer ports d'émission pour les lectures et les écritures. Un exemple est celui des processeurs Intel de micro-architecture Nehalem, qui avaient un port de lecture et un port d'écriture tous deux reliés à la ''Load-store queue'', chacun avec leur propre unité de calcul d'adresse. Comme autre exemple, les processeurs skylake ont une LSQ avec deux ports de lecture et un port d'écriture, ce qui permet d'émettre deux lectures en même temps qu'une écriture. Dans cette section, nous allons étudier le cas avec des ports séparés pour les lectures et écritures. En général, les processeurs ont un peu plus de ports de lectures que d'écritures. Une raison est qu'un programme a, en moyenne, un tout petit peu plus de lectures que d'écritures. Mais la différence n'est pas énorme, c'est un léger déséquilibre. Une seconde raison est que l'exécution dans le désordre marche bien mieux quand les lectures ont lieu le plus tôt possible. Donc mieux vaut privilégier les lectures à l'émission, pour qu'elles se fassent un peu en avance. En pratique, la différence n'est pas très marquée. Il est courant d'avoir un port de lecture et un d'écriture, les CPU superscalaires très larges ont souvent deux ports de lecture et deux d'écriture. A la rigueur, quelques CPU ont trois ports de lecture et deux d'écriture. Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> 7rnyj7ef8exdxwhpyg9ro382j3cj9sh 774151 774150 2026-10-04T15:41:40Z Mewtow 31375 /* L'émission séparée des LOAD et STORE */ 774151 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. [[File:AMD K5.jpg|centre|vignette|upright=3|AMD K5]] Le cas précédent est tout de même rare. Avoir plusieurs AGUs mais un cache simple port ne permet pas d'avoir des gains de performance dignes de ce nom. Les performances sont cependant légèrement augmentées, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Les autres combinaisons, avec deux lectures ou deux écritures, sont interdites. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre des calculs d'adresse pour deux STORE en même temps, ou deux calculs d'adresse pour deux LOAD. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Une telle dé-spécialisation des AGUs permet de calculer des adresses en avance, ce qui aide la désambiguïsation mémoire à faire son travail. En pratique, cette optimisation n'est pas fréquente, la majorité des processeurs x86 sépare bien les AGU avec une dédiée aux lectures et une autre dédiée aux écritures. Quelques processeurs l'ont utilisée, mais ce ne sont pas la majorité. La raison est que si cette organisation permet de calculer des adresses en avance, elle marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. Les processeurs superscalaires tendent à avoir des '''AGUs spécialisées''', avec des AGU LOAD qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des AGU STORE qui font les calculs d'adresse pour les écritures. En général, les processeurs ont un peu plus d'AGU pour les lectures que d'AGU pour les écritures. Une raison est qu'un programme fait en moyenne, deux lectures pour une écriture. L'usage d'AGU spécialisées se comprend mieux quand on sait que les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD? et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> 377yq044t98vnpxygcqiqmoaauysgqv 774152 774151 2026-10-04T15:49:07Z Mewtow 31375 /* L'émission multiple des accès mémoire avec une LSQ */ 774152 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. [[File:AMD K5.jpg|centre|vignette|upright=3|AMD K5]] Le cas précédent est tout de même rare. Avoir plusieurs AGUs mais un cache simple port ne permet pas d'avoir des gains de performance dignes de ce nom. Les performances sont cependant légèrement augmentées, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Les autres combinaisons, avec deux lectures ou deux écritures, sont interdites. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre des calculs d'adresse pour deux STORE en même temps, ou deux calculs d'adresse pour deux LOAD. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Les '''AGUs banalisées''' de ce type permettent de calculer des adresses en avance, ce qui aide la désambiguïsation mémoire à faire son travail. Les processeurs superscalaires tendent à avoir des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. En général, les processeurs ont un peu plus d'AGU pour les lectures que d'AGU pour les écritures. Un programme fait en moyenne deux lectures pour une écriture, ce qui fait qu'il est fréquent d'avoir deux AGU LOAD et une AGU STORE. Pour les processeurs superscalaires vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE. En pratique, la majorité des processeurs x86 sépare bien les AGU avec une dédiée aux lectures et une autre dédiée aux écritures. Quelques processeurs l'ont utilisée, mais ce ne sont pas la majorité. La raison est que si cette organisation permet de calculer des adresses en avance, elle marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. En effet, la désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache). L'usage d'AGU spécialisées colle aussi mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> n5esvff2k14chtiuxw7zel5nzf1yudp 774153 774152 2026-10-04T16:05:25Z Mewtow 31375 /* L'émission multiple des accès mémoire avec une LSQ */ 774153 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. [[File:AMD K5.jpg|centre|vignette|upright=3|AMD K5]] Le cas précédent est tout de même rare. Avoir plusieurs AGUs mais un cache simple port ne permet pas d'avoir des gains de performance dignes de ce nom. Les performances sont cependant légèrement augmentées, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre deux calculs d'adresse pour deux STORE en même temps. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Et cela nous amène à un concept très important : la spécialisation des unités de calcul d'adresse. Un processeur superscalaire peut utiliser des '''AGUs banalisées''', à savoir des unités de calcul qui font des calcul d'adresse sans se soucier de savoir si l'adresse est celle d'une lecture ou d'une écriture. A l'opposé, le processeur peut aussi utiliser des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. Les deux solutions sont utilisées sur les processeurs Intel et AMD modernes. Les processeurs Intel tendent à utiliser des AGU spécialisées, alors qu'AMD a plus tendance à utiliser des AGU banalisées. Pour donner un exemple, la microarchitecture Zen 5 d'AMD utilise 4 AGU banalisées, qui alimente une LSQ assez large. Le cache de ce processeur permet de faire 4 accès mémoire par cycle, avec deux écritures par cycle maximum. Il a donc deux ports de lecture et deux ports banalisés, ce qui ne colle pas avec les 4 AGU banalisées. Mais la LSQ fait l'interface entre les deux. S'il utilise des AGUs spécialisées, le processeur a en général deux fois plus d'AGU pour les lectures que d'AGU pour les écritures. Il faut dire qu'un programme fait en moyenne deux lectures pour une écriture. Les premiers CPU superscalaires x86 avaient généralement deux AGU LOAD pour une AGU STORE. Pour les processeurs superscalaires actuels, qui sont vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE. Utiliser des AGU banalisées permet de calculer des adresses en avance, mais marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. En effet, la désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache). L'usage d'AGU spécialisées colle aussi mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> kzmelwavzpcdn28ykxvpuz2t633j3tu 774154 774153 2026-10-04T16:11:35Z Mewtow 31375 /* L'émission multiple des accès mémoire avec une LSQ */ 774154 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. [[File:AMD K5.jpg|centre|vignette|upright=3|AMD K5]] Le cas précédent est tout de même rare. Avoir plusieurs AGUs mais un cache simple port ne permet pas d'avoir des gains de performance dignes de ce nom. Les performances sont cependant légèrement augmentées, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre deux calculs d'adresse pour deux STORE en même temps. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Et cela nous amène à un concept très important : la spécialisation des unités de calcul d'adresse. Un processeur superscalaire peut utiliser des '''AGUs banalisées''', à savoir des unités de calcul qui font des calcul d'adresse sans se soucier de savoir si l'adresse est celle d'une lecture ou d'une écriture. A l'opposé, le processeur peut aussi utiliser des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. Les deux solutions sont utilisées sur les processeurs Intel et AMD modernes. Les processeurs Intel tendent à utiliser des AGU spécialisées, alors qu'AMD a plus tendance à utiliser des AGU banalisées. Pour donner un exemple, la microarchitecture Zen 5 d'AMD utilise 4 AGU banalisées, alors que le cache a deux ports de lecture et deux ports banalisés (capables de faire lectures et écritures). Les deux ne collent pas, mais la LSQ fait l'interface entre les deux. Elle peut absorber les adresses d'écriture calculées en trop. A l'inverse, le Cortex X925 d'ARM est dans la situation ivnerse. Il dispose de 4 AGU, dont deux sont banalisées et deux autres sont des AGU LOAD. Par contre, le cache du processeur peut faire 4 lectures ou 4 écritures par cycle. S'il utilise des AGUs spécialisées, le processeur a en général deux fois plus d'AGU pour les lectures que d'AGU pour les écritures. Il faut dire qu'un programme fait en moyenne deux lectures pour une écriture. Les premiers CPU superscalaires x86 avaient généralement deux AGU LOAD pour une AGU STORE. Pour les processeurs superscalaires actuels, qui sont vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE. Utiliser des AGU banalisées permet de calculer des adresses en avance, mais marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. En effet, la désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache). L'usage d'AGU spécialisées colle aussi mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] Le cas le plus simple est celui où on peut émettre une lecture et une écriture en même temps. Il s'implémente facilement si la LSQ est en réalité une simple ''Store queue'', à savoir qu'elle met en avance les écritures uniquement. Pour rappel, les écritures sont mises en attente dans la ''Store queue'', mais les lectures sont exécutées immédiatement. Une fois l'adresse à lire connue, la lecture consulte la ''Store queue'' pour détecter les dépendances de données, puis s'exécute. La donnée à lire est lue soit depuis la ''Store queue'', soit depuis le cache. [[File:Emission LOAD - Store séparée.png|vignette|upright=1|Émission LOAD - Store séparée]] L'implémentation de l'émission multiple est alors très simple. La ''Store queue'' a déjà deux ports, avec un pour les lectures et un autre pour les écritures. Idem pour le cache, qui a naturellement un port de lecture et un d'écriture. Émettre une lecture et une écriture simultanément ne demande donc pas de modification digne de ce nom. La ''Store queue'' doit juste supporter une lecture et une écriture simultanée. Ajouter l'émission d'une seconde lecture n'est pas compliquée : il faut juste rajouter un second port de lecture sur le cache et la ''Store queue''. Pour rappel, l'unité mémoire est précédée par une structure qui met en attente les micro-opérations mémoire avant leur émission finale. La structure en question varie suivant l'implémentation, mais un cas particulier se marie bien avec l'émission multiple des µops mémoire. Il s'agit du cas avec une file de µops séparée pour les lectures et une autre pour les écritures, appelées respectivement la ''file de µops LOAD'' et la ''file de µops STORE''. Il est alors possible d'émettre une lecture et une écriture lors du même cycle d'horloge. Le port de lecture alimente une unité de calcul d'adresse dédiée, directement reliée au cache. Le port d'écriture du cache alimente une unité de calcul, qui est suivie par une file d'écriture, elle-même reliée au cache. Les processeurs AMD de micro-architecture K6 utilisaient cette technique. ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> hi89pxxiug2wzr682z1j94qm4phejlv 774155 774154 2026-10-04T16:25:41Z Mewtow 31375 /* L'émission multiple des accès mémoire avec une LSQ */ 774155 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Nous désignerons cette situation un '''excès d'AGU''', sous-entendu "excès par rapport au cache". [[File:AMD K5.jpg|centre|vignette|upright=3|AMD K5]] Le cas précédent est tout de même rare. Utiliser plusieurs AGUs avec un cache simple port augmente légèrement les performances, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre deux calculs d'adresse pour deux STORE en même temps. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Et cela nous amène à un concept très important : la spécialisation des unités de calcul d'adresse. Un processeur superscalaire peut utiliser des '''AGUs banalisées''', à savoir des unités de calcul qui font des calcul d'adresse sans se soucier de savoir si l'adresse est celle d'une lecture ou d'une écriture. A l'opposé, le processeur peut aussi utiliser des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. Les deux solutions sont utilisées sur les processeurs Intel et AMD modernes. Les processeurs Intel tendent à utiliser des AGU spécialisées, alors qu'AMD a plus tendance à utiliser des AGU banalisées. Pour donner un exemple, la microarchitecture Zen 5 d'AMD utilise 4 AGU banalisées, alors que le cache a deux ports de lecture et deux ports banalisés (capables de faire lectures et écritures). Les deux ne collent pas, mais la LSQ fait l'interface entre les deux. Elle peut absorber les adresses d'écriture calculées en trop. A l'inverse, le Cortex X925 d'ARM est dans la situation ivnerse. Il dispose de 4 AGU, dont deux sont banalisées et deux autres sont des AGU LOAD. Par contre, le cache du processeur peut faire 4 lectures ou 4 écritures par cycle. Ne parlons pas des premières microarchitectures AMD, de la K7, K8, K9 et K10, qui avaient trois AGU banalisées, mais un cache double port. [[File:AMD K8, microarchitecture.png|centre|vignette|upright=3|AMD K8, microarchitecture.]] S'il utilise des AGUs spécialisées, le processeur a en général deux fois plus d'AGU pour les lectures que d'AGU pour les écritures. Il faut dire qu'un programme fait en moyenne deux lectures pour une écriture. Les premiers CPU superscalaires x86 avaient généralement deux AGU LOAD pour une AGU STORE. Pour les processeurs superscalaires actuels, qui sont vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE. Utiliser des AGU banalisées permet de calculer des adresses en avance, mais marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. En effet, la désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache). L'usage d'AGU spécialisées colle aussi mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> b9t3dbe7f0f0n47d8m4w1je01dc62s2 774156 774155 2026-10-04T16:26:47Z Mewtow 31375 /* L'émission multiple des accès mémoire avec une LSQ */ 774156 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==Les µops mémoire sur les processeurs superscalaires == Plus haut, nous avons volontairement passé sous silence les accès mémoire. La raison à cela est que leur support sur les CPU superscalaire peut se faire de plusieurs manières différentes, qu'il vaut mieux expliquer dans une section dédiée. Les différentes implémentations sont décrites dans la liste suivante, nous allons les voir une par une. * Avec la première méthode, les accès mémoire sont exécutés par le pipeline pour les opérations entières. * Avec la seconde méthode, les accès mémoire ont un pipeline dédié ; c'est l'''émission parallèle des accès mémoire''. * Avec la troisième méthode, il est possible d'émettre plusieurs accès mémoire simultanément : c'est l'''émission multiple des accès mémoire''. Voyons ces trois cas dans le détail. Nous verrons notamment les différentes formes d'émission multiple des accès mémoire. ===Les accès mémoire traités dans le pipeline entier=== Dans le cas le plus simple, les accès mémoire sont traités comme des opérations entières un peu spéciales. L'unité mémoire est sur le même port d'émission que l'ALU entière, ce qui fait que les accès mémoire remplacent des opérations entières. Faire ainsi permet d'utiliser une ALU entière pour les calculs d'adresse, pas besoin de rajouter une unité de calcul d'adresse séparée. Typiquement, un processeur de ce type place l'unité mémoire après l'unité de calcul. Les opérations entières n'utilisent que l'ALU entière, mais pas l'unité mémoire. Les accès mémoire exécutent un calcul d'adresse dans l'ALU entière, puis l'adresse calculée est envoyée à l'unité mémoire. Le tout est illustré ci-dessous, dans le cas d'un processeur historique à double émission entière-flottante. [[File:Processeur non-superscalaire basique 01.png|centre|vignette|upright=1.5|Processeur non-superscalaire basique]] L'implémentation économise une unité de calcul d'adresse, mais n'a pas de cout en performance significatif. Les accès mémoire étant très lents, émettre une µop entière en parallèle d'un accès mémoire ne sert pas à grand-chose. On peut émettre cette µop entière un cycle plus tard, elle sera terminée avant même que l'accès mémoire soit terminé, même si l'accès mémoire se fait dans le cache L1 ou L2. Pour comprendre pourquoi, faisons quelques calculs. Un accès mémoire prend deux-trois cycles quand il tombe dans le cache L1. Et on doit attendre qu'il soit terminé pour en émettre un nouveau. Combien d'instructions arithmétiques peut-on lancer pendant ce temps, sur un CPU triple émission ? Au premier cycle, on peut en émettre 2 (l'instruction mémoire prend une place, donc 3-1), puis 3 au second cycle. Ce qui fait 5 instructions arithmétiques au total. Or, il se trouve qu'il y a une instruction d'accès mémoire pour 5 instructions arithmétiques, environ. Par contre, ce compromis change quand on passe à la quadruple émission, voire au-delà. Dans ce cas, il devient intéressant de pouvoir émettre une nouvelle instruction mémoire à chaque cycle. Ce qui nous amène à la section suivante... ===L'émission parallèle des accès mémoire=== A partir de la quadruple émission, la tendance a été de séparer l'unité mémoire des ALU entières. Cela permet d'émettre des µops mémoire en même temps que des µops entières et/ou flottantes., ce qui porte le nom d''''émission parallèle des µops mémoire''', ou encore ''émission mémoire parallèle''. Par exemple, un processeur quadruple émission peut émettre en même temps deux opérations entières, une opération flottante et un accès mémoire. L'accès mémoire vient en plus des opérations entières, il n'est pas possible de remplacer l'accès mémoire par une opération entière. La technique ne permet pas de faire les calculs d'adresse dans l'ALU entière. Il faut donc utiliser une unité de calcul d'adresse séparée. Elle est intégrée dans l'unité mémoire, qui fait l'interface entre le cache et le banc de registres. Le cout en circuits est très faible, car les unités de calcul d'adresse n'utilisent guère plus qu'un additionneur et un circuit de décalage très simple. L'émission mémoire parallèle est plus facile à implémenter sur les CPU RISC, qui ont des instructions mémoire séparées du reste. Les CPU CISC ne sont pas dans ce cas, ce qui fait que séparer l'unité mémoire du pipeline entier est compliqué. Les CPU superscalaires historiques étaient pour moité des CPU RISC et pour moitié des CPU CISC, ce qui fait qu'ils n'utilisaient pas ou peu l'émission mémoire parallèle. Les premiers processeurs superscalaires se contenaient de l'émission parallèle mémoire, ils ne pouvaient émettre qu'un seul accès mémoire à la fois, l'unité mémoire n'était pas dupliquée. Et ce n'est pas tant un problème que ça, du moins sur les processeurs à double ou triple émission. En effet, il est rare que l'unité mémoire doive faire deux accès simultanés. Notons que le processeur peut exécuter une instruction mémoire en parallèle d'un calcul ou d'un branchement, seuls les accès mémoire simultanés sont impossibles. ===L'émission multiple des accès mémoire avec une LSQ=== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Nous désignerons cette situation un '''excès d'AGU''', sous-entendu "excès par rapport au cache". Le cas précédent est tout de même rare. Utiliser plusieurs AGUs avec un cache simple port augmente légèrement les performances, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre deux calculs d'adresse pour deux STORE en même temps. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Et cela nous amène à un concept très important : la spécialisation des unités de calcul d'adresse. Un processeur superscalaire peut utiliser des '''AGUs banalisées''', à savoir des unités de calcul qui font des calcul d'adresse sans se soucier de savoir si l'adresse est celle d'une lecture ou d'une écriture. A l'opposé, le processeur peut aussi utiliser des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. Les deux solutions sont utilisées sur les processeurs Intel et AMD modernes. Les processeurs Intel tendent à utiliser des AGU spécialisées, alors qu'AMD a plus tendance à utiliser des AGU banalisées. Pour donner un exemple, la microarchitecture Zen 5 d'AMD utilise 4 AGU banalisées, alors que le cache a deux ports de lecture et deux ports banalisés (capables de faire lectures et écritures). Les deux ne collent pas, mais la LSQ fait l'interface entre les deux. Elle peut absorber les adresses d'écriture calculées en trop. A l'inverse, le Cortex X925 d'ARM est dans la situation ivnerse. Il dispose de 4 AGU, dont deux sont banalisées et deux autres sont des AGU LOAD. Par contre, le cache du processeur peut faire 4 lectures ou 4 écritures par cycle. Ne parlons pas des premières microarchitectures AMD, de la K7, K8, K9 et K10, qui avaient trois AGU banalisées, mais un cache avec deux ports (un de lecture, un d'écriture). S'il utilise des AGUs spécialisées, le processeur a en général deux fois plus d'AGU pour les lectures que d'AGU pour les écritures. Il faut dire qu'un programme fait en moyenne deux lectures pour une écriture. Les premiers CPU superscalaires x86 avaient généralement deux AGU LOAD pour une AGU STORE. Pour les processeurs superscalaires actuels, qui sont vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE. Utiliser des AGU banalisées permet de calculer des adresses en avance, mais marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. En effet, la désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache). L'usage d'AGU spécialisées colle aussi mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> 1afvyebs2izrhfeueqx1mrwj9eur5sx 774158 774156 2026-10-04T17:06:11Z Mewtow 31375 /* Les µops mémoire sur les processeurs superscalaires */ 774158 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==L'émission multiple des accès mémoire avec une LSQ== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Nous désignerons cette situation un '''excès d'AGU''', sous-entendu "excès par rapport au cache". Le cas précédent est tout de même rare. Utiliser plusieurs AGUs avec un cache simple port augmente légèrement les performances, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre deux calculs d'adresse pour deux STORE en même temps. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Et cela nous amène à un concept très important : la spécialisation des unités de calcul d'adresse. Un processeur superscalaire peut utiliser des '''AGUs banalisées''', à savoir des unités de calcul qui font des calcul d'adresse sans se soucier de savoir si l'adresse est celle d'une lecture ou d'une écriture. A l'opposé, le processeur peut aussi utiliser des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. Les deux solutions sont utilisées sur les processeurs Intel et AMD modernes. Les processeurs Intel tendent à utiliser des AGU spécialisées, alors qu'AMD a plus tendance à utiliser des AGU banalisées. Pour donner un exemple, la microarchitecture Zen 5 d'AMD utilise 4 AGU banalisées, alors que le cache a deux ports de lecture et deux ports banalisés (capables de faire lectures et écritures). Les deux ne collent pas, mais la LSQ fait l'interface entre les deux. Elle peut absorber les adresses d'écriture calculées en trop. A l'inverse, le Cortex X925 d'ARM est dans la situation ivnerse. Il dispose de 4 AGU, dont deux sont banalisées et deux autres sont des AGU LOAD. Par contre, le cache du processeur peut faire 4 lectures ou 4 écritures par cycle. Ne parlons pas des premières microarchitectures AMD, de la K7, K8, K9 et K10, qui avaient trois AGU banalisées, mais un cache avec deux ports (un de lecture, un d'écriture). S'il utilise des AGUs spécialisées, le processeur a en général deux fois plus d'AGU pour les lectures que d'AGU pour les écritures. Il faut dire qu'un programme fait en moyenne deux lectures pour une écriture. Les premiers CPU superscalaires x86 avaient généralement deux AGU LOAD pour une AGU STORE. Pour les processeurs superscalaires actuels, qui sont vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE. Utiliser des AGU banalisées permet de calculer des adresses en avance, mais marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. En effet, la désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache). L'usage d'AGU spécialisées colle aussi mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> 4x7kuup2wzfzeg3dzz0644lnp5poduf 774160 774158 2026-10-04T17:58:19Z Mewtow 31375 /* L'émission multiple des accès mémoire avec une LSQ */ 774160 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==L'émission multiple des accès mémoire avec une LSQ== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. ===L'excès d'AGU avec une LSQ simple=== Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Nous désignerons cette situation un '''excès d'AGU''', sous-entendu "excès par rapport au cache". Le cas précédent est tout de même rare. Utiliser plusieurs AGUs avec un cache simple port augmente légèrement les performances, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. ===L'ajout de ports sur le cache=== [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre deux calculs d'adresse pour deux STORE en même temps. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Et cela nous amène à un concept très important : la spécialisation des unités de calcul d'adresse. Un processeur superscalaire peut utiliser des '''AGUs banalisées''', à savoir des unités de calcul qui font des calcul d'adresse sans se soucier de savoir si l'adresse est celle d'une lecture ou d'une écriture. A l'opposé, le processeur peut aussi utiliser des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. Les deux solutions sont utilisées sur les processeurs Intel et AMD modernes. Les processeurs Intel tendent à utiliser des AGU spécialisées, alors qu'AMD a plus tendance à utiliser des AGU banalisées. Pour donner un exemple, la microarchitecture Zen 5 d'AMD utilise 4 AGU banalisées, alors que le cache a deux ports de lecture et deux ports banalisés (capables de faire lectures et écritures). Les deux ne collent pas, mais la LSQ fait l'interface entre les deux. Elle peut absorber les adresses d'écriture calculées en trop. A l'inverse, le Cortex X925 d'ARM est dans la situation ivnerse. Il dispose de 4 AGU, dont deux sont banalisées et deux autres sont des AGU LOAD. Par contre, le cache du processeur peut faire 4 lectures ou 4 écritures par cycle. Ne parlons pas des premières microarchitectures AMD, de la K7, K8, K9 et K10, qui avaient trois AGU banalisées, mais un cache avec deux ports (un de lecture, un d'écriture). S'il utilise des AGUs spécialisées, le processeur a en général deux fois plus d'AGU pour les lectures que d'AGU pour les écritures. Il faut dire qu'un programme fait en moyenne deux lectures pour une écriture. Les premiers CPU superscalaires x86 avaient généralement deux AGU LOAD pour une AGU STORE. Pour les processeurs superscalaires actuels, qui sont vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE. ===AGUs banalisées versus spécialisées=== Utiliser des AGU banalisées permet de calculer des adresses en avance, mais marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. En effet, la désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache). L'usage d'AGU spécialisées colle aussi mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> 39jvys176xpavrroqsnuul34sqoft5d 774161 774160 2026-10-04T17:59:01Z Mewtow 31375 774161 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==L'émission multiple des accès mémoire avec une LSQ== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. ===L'excès d'AGU avec une LSQ simple=== Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Nous désignerons cette situation un '''excès d'AGU''', sous-entendu "excès par rapport au cache". Le cas précédent est tout de même rare. Utiliser plusieurs AGUs avec un cache simple port augmente légèrement les performances, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. ===L'ajout de ports sur le cache=== [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre deux calculs d'adresse pour deux STORE en même temps. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Et cela nous amène à un concept très important : la spécialisation des unités de calcul d'adresse. Un processeur superscalaire peut utiliser des '''AGUs banalisées''', à savoir des unités de calcul qui font des calcul d'adresse sans se soucier de savoir si l'adresse est celle d'une lecture ou d'une écriture. A l'opposé, le processeur peut aussi utiliser des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. Les deux solutions sont utilisées sur les processeurs Intel et AMD modernes. Les processeurs Intel tendent à utiliser des AGU spécialisées, alors qu'AMD a plus tendance à utiliser des AGU banalisées. Pour donner un exemple, la microarchitecture Zen 5 d'AMD utilise 4 AGU banalisées, alors que le cache a deux ports de lecture et deux ports banalisés (capables de faire lectures et écritures). Les deux ne collent pas, mais la LSQ fait l'interface entre les deux. Elle peut absorber les adresses d'écriture calculées en trop. A l'inverse, le Cortex X925 d'ARM est dans la situation ivnerse. Il dispose de 4 AGU, dont deux sont banalisées et deux autres sont des AGU LOAD. Par contre, le cache du processeur peut faire 4 lectures ou 4 écritures par cycle. Ne parlons pas des premières microarchitectures AMD, de la K7, K8, K9 et K10, qui avaient trois AGU banalisées, mais un cache avec deux ports (un de lecture, un d'écriture). S'il utilise des AGUs spécialisées, le processeur a en général deux fois plus d'AGU pour les lectures que d'AGU pour les écritures. Il faut dire qu'un programme fait en moyenne deux lectures pour une écriture. Les premiers CPU superscalaires x86 avaient généralement deux AGU LOAD pour une AGU STORE. Pour les processeurs superscalaires actuels, qui sont vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE. ===AGUs banalisées versus spécialisées=== Utiliser des AGU banalisées permet de calculer des adresses en avance, mais marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. En effet, la désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache). L'usage d'AGU spécialisées colle aussi mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> gma86j7phlla1x6j7s7jmnw573km7we 774162 774161 2026-10-04T17:59:21Z Mewtow 31375 /* L'émission multiple des accès mémoire avec une LSQ */ 774162 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==L'unité mémoire/LSQ des processeurs superscalaires== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. ===L'excès d'AGU avec une LSQ simple=== Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Nous désignerons cette situation un '''excès d'AGU''', sous-entendu "excès par rapport au cache". Le cas précédent est tout de même rare. Utiliser plusieurs AGUs avec un cache simple port augmente légèrement les performances, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. ===L'ajout de ports sur le cache=== [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre deux calculs d'adresse pour deux STORE en même temps. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Et cela nous amène à un concept très important : la spécialisation des unités de calcul d'adresse. Un processeur superscalaire peut utiliser des '''AGUs banalisées''', à savoir des unités de calcul qui font des calcul d'adresse sans se soucier de savoir si l'adresse est celle d'une lecture ou d'une écriture. A l'opposé, le processeur peut aussi utiliser des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. Les deux solutions sont utilisées sur les processeurs Intel et AMD modernes. Les processeurs Intel tendent à utiliser des AGU spécialisées, alors qu'AMD a plus tendance à utiliser des AGU banalisées. Pour donner un exemple, la microarchitecture Zen 5 d'AMD utilise 4 AGU banalisées, alors que le cache a deux ports de lecture et deux ports banalisés (capables de faire lectures et écritures). Les deux ne collent pas, mais la LSQ fait l'interface entre les deux. Elle peut absorber les adresses d'écriture calculées en trop. A l'inverse, le Cortex X925 d'ARM est dans la situation ivnerse. Il dispose de 4 AGU, dont deux sont banalisées et deux autres sont des AGU LOAD. Par contre, le cache du processeur peut faire 4 lectures ou 4 écritures par cycle. Ne parlons pas des premières microarchitectures AMD, de la K7, K8, K9 et K10, qui avaient trois AGU banalisées, mais un cache avec deux ports (un de lecture, un d'écriture). S'il utilise des AGUs spécialisées, le processeur a en général deux fois plus d'AGU pour les lectures que d'AGU pour les écritures. Il faut dire qu'un programme fait en moyenne deux lectures pour une écriture. Les premiers CPU superscalaires x86 avaient généralement deux AGU LOAD pour une AGU STORE. Pour les processeurs superscalaires actuels, qui sont vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE. ===AGUs banalisées versus spécialisées=== Utiliser des AGU banalisées permet de calculer des adresses en avance, mais marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. En effet, la désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache). L'usage d'AGU spécialisées colle aussi mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> 5nig765uin2ugm6k6xqeuwq4q6f8a9y 774163 774162 2026-10-04T18:03:16Z Mewtow 31375 /* AGUs banalisées versus spécialisées */ 774163 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==L'unité mémoire/LSQ des processeurs superscalaires== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. ===L'excès d'AGU avec une LSQ simple=== Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Nous désignerons cette situation un '''excès d'AGU''', sous-entendu "excès par rapport au cache". Le cas précédent est tout de même rare. Utiliser plusieurs AGUs avec un cache simple port augmente légèrement les performances, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. ===L'ajout de ports sur le cache=== [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre deux calculs d'adresse pour deux STORE en même temps. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Et cela nous amène à un concept très important : la spécialisation des unités de calcul d'adresse. Un processeur superscalaire peut utiliser des '''AGUs banalisées''', à savoir des unités de calcul qui font des calcul d'adresse sans se soucier de savoir si l'adresse est celle d'une lecture ou d'une écriture. A l'opposé, le processeur peut aussi utiliser des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. Les deux solutions sont utilisées sur les processeurs Intel et AMD modernes. Les processeurs Intel tendent à utiliser des AGU spécialisées, alors qu'AMD a plus tendance à utiliser des AGU banalisées. Pour donner un exemple, la microarchitecture Zen 5 d'AMD utilise 4 AGU banalisées, alors que le cache a deux ports de lecture et deux ports banalisés (capables de faire lectures et écritures). Les deux ne collent pas, mais la LSQ fait l'interface entre les deux. Elle peut absorber les adresses d'écriture calculées en trop. A l'inverse, le Cortex X925 d'ARM est dans la situation ivnerse. Il dispose de 4 AGU, dont deux sont banalisées et deux autres sont des AGU LOAD. Par contre, le cache du processeur peut faire 4 lectures ou 4 écritures par cycle. Ne parlons pas des premières microarchitectures AMD, de la K7, K8, K9 et K10, qui avaient trois AGU banalisées, mais un cache avec deux ports (un de lecture, un d'écriture). S'il utilise des AGUs spécialisées, le processeur a en général deux fois plus d'AGU pour les lectures que d'AGU pour les écritures. Il faut dire qu'un programme fait en moyenne deux lectures pour une écriture. Les premiers CPU superscalaires x86 avaient généralement deux AGU LOAD pour une AGU STORE. Pour les processeurs superscalaires actuels, qui sont vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE. ===AGUs banalisées versus spécialisées=== Utiliser des AGU banalisées permet de calculer des adresses en avance, mais marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. En effet, la désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache). L'usage d'AGU spécialisées colle aussi mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] Passons maintenant aux avantages/inconvénients au niveau du ''scheduler''. L'usage d'AGUs spécialisées impose des contraintes pour les ''scheduler''. Déjà, cela demande que la µops AGU dise si elle pour une lecture ou une écriture. Et le ''scheduler'' doit envoyer les µop AGU LOAD vers l'AGU LOAD, les µops AGU STORE vers l'AGU STORE. Il doit envoyer chaque µop dans le bon canal. L'usage d'AGUs banalisées simplifie grandement la conception du ''scheduler''. Il n'a pas à faire la différence entre µop AGU LOAD ou STORE, il voit juste des µops de calcul d'adresse, qu'il envoie dans les ports d'émission pour les AGUs. Pas besoin d'envoyer la bonne µop dans le bon canal : toutes les AGUs se valent. ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> lq0ci3ubboyu4e54n8pd072ge0r7zwc 774164 774163 2026-10-04T18:06:42Z Mewtow 31375 /* AGUs banalisées versus spécialisées */ 774164 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==L'unité mémoire/LSQ des processeurs superscalaires== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. ===L'excès d'AGU avec une LSQ simple=== Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Nous désignerons cette situation un '''excès d'AGU''', sous-entendu "excès par rapport au cache". Le cas précédent est tout de même rare. Utiliser plusieurs AGUs avec un cache simple port augmente légèrement les performances, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. ===L'ajout de ports sur le cache=== [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre deux calculs d'adresse pour deux STORE en même temps. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Et cela nous amène à un concept très important : la spécialisation des unités de calcul d'adresse. Un processeur superscalaire peut utiliser des '''AGUs banalisées''', à savoir des unités de calcul qui font des calcul d'adresse sans se soucier de savoir si l'adresse est celle d'une lecture ou d'une écriture. A l'opposé, le processeur peut aussi utiliser des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. Les deux solutions sont utilisées sur les processeurs Intel et AMD modernes. Les processeurs Intel tendent à utiliser des AGU spécialisées, alors qu'AMD a plus tendance à utiliser des AGU banalisées. Pour donner un exemple, la microarchitecture Zen 5 d'AMD utilise 4 AGU banalisées, alors que le cache a deux ports de lecture et deux ports banalisés (capables de faire lectures et écritures). Les deux ne collent pas, mais la LSQ fait l'interface entre les deux. Elle peut absorber les adresses d'écriture calculées en trop. A l'inverse, le Cortex X925 d'ARM est dans la situation ivnerse. Il dispose de 4 AGU, dont deux sont banalisées et deux autres sont des AGU LOAD. Par contre, le cache du processeur peut faire 4 lectures ou 4 écritures par cycle. Ne parlons pas des premières microarchitectures AMD, de la K7, K8, K9 et K10, qui avaient trois AGU banalisées, mais un cache avec deux ports (un de lecture, un d'écriture). S'il utilise des AGUs spécialisées, le processeur a en général deux fois plus d'AGU pour les lectures que d'AGU pour les écritures. Il faut dire qu'un programme fait en moyenne deux lectures pour une écriture. Les premiers CPU superscalaires x86 avaient généralement deux AGU LOAD pour une AGU STORE. Pour les processeurs superscalaires actuels, qui sont vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE. ===AGUs banalisées versus spécialisées=== Les AGUs banalisées et spécialisées ont chacune des avantages et inconvénients. La conception générale du processeur n'est pas exactement la même, entre les deux. Les circuits impactés sont les ''schedulers'' et la LSQ. La LSQ car elle receoit les adresses calculées, les ''schedulers'' car ils émettent les µops AGU. L'usage d'AGUs spécialisées impose des contraintes pour les ''schedulers''. Déjà, cela demande que la µops AGU dise si elle pour une lecture ou une écriture. Et le ''scheduler'' doit envoyer les µop AGU LOAD vers l'AGU LOAD, les µops AGU STORE vers l'AGU STORE. Il doit envoyer chaque µop dans le bon canal. L'usage d'AGUs banalisées simplifie grandement la conception du ''scheduler''. Il n'a pas à faire la différence entre µop AGU LOAD ou STORE, il voit juste des µops de calcul d'adresse, qu'il envoie dans les ports d'émission pour les AGUs. Pas besoin d'envoyer la bonne µop dans le bon canal : toutes les AGUs se valent. L'usage d'AGU spécialisées colle mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] Prenons maintenant un point de vue plus abstrait, plus théorique. Utiliser des AGU banalisées permet de calculer des adresses en avance, mais marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. La désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache). ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> 9jocqr6z21yv7dobd76rw3wqvf1obwv 774165 774164 2026-10-04T18:15:43Z Mewtow 31375 /* Les optimisations des processeurs superscalaires larges */ 774165 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==L'unité mémoire/LSQ des processeurs superscalaires== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. ===L'excès d'AGU avec une LSQ simple=== Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Nous désignerons cette situation un '''excès d'AGU''', sous-entendu "excès par rapport au cache". Le cas précédent est tout de même rare. Utiliser plusieurs AGUs avec un cache simple port augmente légèrement les performances, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. ===L'ajout de ports sur le cache=== [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre deux calculs d'adresse pour deux STORE en même temps. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Et cela nous amène à un concept très important : la spécialisation des unités de calcul d'adresse. Un processeur superscalaire peut utiliser des '''AGUs banalisées''', à savoir des unités de calcul qui font des calcul d'adresse sans se soucier de savoir si l'adresse est celle d'une lecture ou d'une écriture. A l'opposé, le processeur peut aussi utiliser des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. Les deux solutions sont utilisées sur les processeurs Intel et AMD modernes. Les processeurs Intel tendent à utiliser des AGU spécialisées, alors qu'AMD a plus tendance à utiliser des AGU banalisées. Pour donner un exemple, la microarchitecture Zen 5 d'AMD utilise 4 AGU banalisées, alors que le cache a deux ports de lecture et deux ports banalisés (capables de faire lectures et écritures). Les deux ne collent pas, mais la LSQ fait l'interface entre les deux. Elle peut absorber les adresses d'écriture calculées en trop. A l'inverse, le Cortex X925 d'ARM est dans la situation ivnerse. Il dispose de 4 AGU, dont deux sont banalisées et deux autres sont des AGU LOAD. Par contre, le cache du processeur peut faire 4 lectures ou 4 écritures par cycle. Ne parlons pas des premières microarchitectures AMD, de la K7, K8, K9 et K10, qui avaient trois AGU banalisées, mais un cache avec deux ports (un de lecture, un d'écriture). S'il utilise des AGUs spécialisées, le processeur a en général deux fois plus d'AGU pour les lectures que d'AGU pour les écritures. Il faut dire qu'un programme fait en moyenne deux lectures pour une écriture. Les premiers CPU superscalaires x86 avaient généralement deux AGU LOAD pour une AGU STORE. Pour les processeurs superscalaires actuels, qui sont vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE. ===AGUs banalisées versus spécialisées=== Les AGUs banalisées et spécialisées ont chacune des avantages et inconvénients. La conception générale du processeur n'est pas exactement la même, entre les deux. Les circuits impactés sont les ''schedulers'' et la LSQ. La LSQ car elle receoit les adresses calculées, les ''schedulers'' car ils émettent les µops AGU. L'usage d'AGUs spécialisées impose des contraintes pour les ''schedulers''. Déjà, cela demande que la µops AGU dise si elle pour une lecture ou une écriture. Et le ''scheduler'' doit envoyer les µop AGU LOAD vers l'AGU LOAD, les µops AGU STORE vers l'AGU STORE. Il doit envoyer chaque µop dans le bon canal. L'usage d'AGUs banalisées simplifie grandement la conception du ''scheduler''. Il n'a pas à faire la différence entre µop AGU LOAD ou STORE, il voit juste des µops de calcul d'adresse, qu'il envoie dans les ports d'émission pour les AGUs. Pas besoin d'envoyer la bonne µop dans le bon canal : toutes les AGUs se valent. L'usage d'AGU spécialisées colle mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] Prenons maintenant un point de vue plus abstrait, plus théorique. Utiliser des AGU banalisées permet de calculer des adresses en avance, mais marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. La désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache). ==Les optimisations des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Les secondes corrigent des problèmes qui apparaissent quand un CPU intègre plus de 5 à 10 unités de calcul. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ==Le ''back-end'' des processeurs superscalaires larges== Les processeurs superscalaires larges incorporent un grand nombre d'unités de calcul. Que ce soit pour des ALU entières, des AGUs ou des FPUs. Un processeur superscalaire actuel, de la moitié des années 2020, contient entre une dizaine et une vingtaine d'unités de calcul, AGU inclues. Par exemple, les processeurs Zen 2, sortis en 2019, avaient exactement 4 ports d'émission pour les ALU entières, 3 pour les AGUs, deux multiplieurs flottants et deux additionneurs flottants. La microarchitecture Gracemont d'Intel, datant approximativement des mêmes années, a 4 ports d'émission pour les ALUs, 2 pour les branchements, 4 pour les AGUs, 3 pour les FPUs. Et ces ports sont de plus partagés. Un grand nombre d'unités de calcul entraine de nombreuses complications. Les unités de calcul doivent être alimentées, ce qui demande d'ajouter des ports de lecture/écriture sur le banc de registres. De plus, le réseau de contournement doit interconnecter toutes les unités de calcul entre elles. Faites les comptes : avec 3 AGUs et 4 ports d'ALUs (partagés), cela fait 14 ports de lecture sur le banc de registres, et 7*7 possibilités d'interconnexions. C'est trop, beaucoup trop. Aussi, diverses optimisations visent à réduire ces couts, pour rendre un grand nombre d'ALU praticable. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> hqkyuemjaukbhfpe4s1kgkq6q2sa8kc 774166 774165 2026-10-04T18:16:07Z Mewtow 31375 /* Les optimisations des processeurs superscalaires larges */ 774166 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==L'unité mémoire/LSQ des processeurs superscalaires== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. ===L'excès d'AGU avec une LSQ simple=== Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Nous désignerons cette situation un '''excès d'AGU''', sous-entendu "excès par rapport au cache". Le cas précédent est tout de même rare. Utiliser plusieurs AGUs avec un cache simple port augmente légèrement les performances, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. ===L'ajout de ports sur le cache=== [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre deux calculs d'adresse pour deux STORE en même temps. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Et cela nous amène à un concept très important : la spécialisation des unités de calcul d'adresse. Un processeur superscalaire peut utiliser des '''AGUs banalisées''', à savoir des unités de calcul qui font des calcul d'adresse sans se soucier de savoir si l'adresse est celle d'une lecture ou d'une écriture. A l'opposé, le processeur peut aussi utiliser des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. Les deux solutions sont utilisées sur les processeurs Intel et AMD modernes. Les processeurs Intel tendent à utiliser des AGU spécialisées, alors qu'AMD a plus tendance à utiliser des AGU banalisées. Pour donner un exemple, la microarchitecture Zen 5 d'AMD utilise 4 AGU banalisées, alors que le cache a deux ports de lecture et deux ports banalisés (capables de faire lectures et écritures). Les deux ne collent pas, mais la LSQ fait l'interface entre les deux. Elle peut absorber les adresses d'écriture calculées en trop. A l'inverse, le Cortex X925 d'ARM est dans la situation ivnerse. Il dispose de 4 AGU, dont deux sont banalisées et deux autres sont des AGU LOAD. Par contre, le cache du processeur peut faire 4 lectures ou 4 écritures par cycle. Ne parlons pas des premières microarchitectures AMD, de la K7, K8, K9 et K10, qui avaient trois AGU banalisées, mais un cache avec deux ports (un de lecture, un d'écriture). S'il utilise des AGUs spécialisées, le processeur a en général deux fois plus d'AGU pour les lectures que d'AGU pour les écritures. Il faut dire qu'un programme fait en moyenne deux lectures pour une écriture. Les premiers CPU superscalaires x86 avaient généralement deux AGU LOAD pour une AGU STORE. Pour les processeurs superscalaires actuels, qui sont vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE. ===AGUs banalisées versus spécialisées=== Les AGUs banalisées et spécialisées ont chacune des avantages et inconvénients. La conception générale du processeur n'est pas exactement la même, entre les deux. Les circuits impactés sont les ''schedulers'' et la LSQ. La LSQ car elle receoit les adresses calculées, les ''schedulers'' car ils émettent les µops AGU. L'usage d'AGUs spécialisées impose des contraintes pour les ''schedulers''. Déjà, cela demande que la µops AGU dise si elle pour une lecture ou une écriture. Et le ''scheduler'' doit envoyer les µop AGU LOAD vers l'AGU LOAD, les µops AGU STORE vers l'AGU STORE. Il doit envoyer chaque µop dans le bon canal. L'usage d'AGUs banalisées simplifie grandement la conception du ''scheduler''. Il n'a pas à faire la différence entre µop AGU LOAD ou STORE, il voit juste des µops de calcul d'adresse, qu'il envoie dans les ports d'émission pour les AGUs. Pas besoin d'envoyer la bonne µop dans le bon canal : toutes les AGUs se valent. L'usage d'AGU spécialisées colle mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] Prenons maintenant un point de vue plus abstrait, plus théorique. Utiliser des AGU banalisées permet de calculer des adresses en avance, mais marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. La désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache). ==Le ''front-end'' des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Voyons ce qu'il en est. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ==Le ''back-end'' des processeurs superscalaires larges== Les processeurs superscalaires larges incorporent un grand nombre d'unités de calcul. Que ce soit pour des ALU entières, des AGUs ou des FPUs. Un processeur superscalaire actuel, de la moitié des années 2020, contient entre une dizaine et une vingtaine d'unités de calcul, AGU inclues. Par exemple, les processeurs Zen 2, sortis en 2019, avaient exactement 4 ports d'émission pour les ALU entières, 3 pour les AGUs, deux multiplieurs flottants et deux additionneurs flottants. La microarchitecture Gracemont d'Intel, datant approximativement des mêmes années, a 4 ports d'émission pour les ALUs, 2 pour les branchements, 4 pour les AGUs, 3 pour les FPUs. Et ces ports sont de plus partagés. Un grand nombre d'unités de calcul entraine de nombreuses complications. Les unités de calcul doivent être alimentées, ce qui demande d'ajouter des ports de lecture/écriture sur le banc de registres. De plus, le réseau de contournement doit interconnecter toutes les unités de calcul entre elles. Faites les comptes : avec 3 AGUs et 4 ports d'ALUs (partagés), cela fait 14 ports de lecture sur le banc de registres, et 7*7 possibilités d'interconnexions. C'est trop, beaucoup trop. Aussi, diverses optimisations visent à réduire ces couts, pour rendre un grand nombre d'ALU praticable. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> dbkpy42zl0tjrgkjxjb00aouwqsscpa 774172 774166 2026-10-04T19:36:56Z Mewtow 31375 /* L'ajout de ports sur le cache */ 774172 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==L'unité mémoire/LSQ des processeurs superscalaires== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. ===L'excès d'AGU avec une LSQ simple=== Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Nous désignerons cette situation un '''excès d'AGU''', sous-entendu "excès par rapport au cache". Le cas précédent est tout de même rare. Utiliser plusieurs AGUs avec un cache simple port augmente légèrement les performances, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. ===L'ajout de ports sur le cache=== [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre deux calculs d'adresse pour deux STORE en même temps. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Et cela nous amène à un concept très important : la spécialisation des unités de calcul d'adresse. Un processeur superscalaire peut utiliser des '''AGUs banalisées''', à savoir des unités de calcul qui font des calcul d'adresse sans se soucier de savoir si l'adresse est celle d'une lecture ou d'une écriture. A l'opposé, le processeur peut aussi utiliser des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. Les deux solutions sont utilisées sur les processeurs Intel et AMD modernes. Les processeurs Intel tendent à utiliser des AGU spécialisées, alors qu'AMD a plus tendance à utiliser des AGU banalisées. [[File:Emissim multiple des µops mémoire.png|vignette|upright=1|Emission multiple des µops mémoire avec des AGUs spécialisées.]] S'il utilise des AGUs spécialisées, le processeur a en général deux fois plus d'AGU pour les lectures que d'AGU pour les écritures. Il faut dire qu'un programme fait en moyenne deux lectures pour une écriture. Les premiers CPU superscalaires x86 avaient généralement deux AGU LOAD pour une AGU STORE. Pour les processeurs superscalaires actuels, qui sont vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE. Pour donner un exemple, la microarchitecture Zen 5 d'AMD utilise 4 AGU banalisées, alors que le cache a deux ports de lecture et deux ports banalisés (capables de faire lectures et écritures). Les deux ne collent pas, mais la LSQ fait l'interface entre les deux. Elle peut absorber les adresses d'écriture calculées en trop. A l'inverse, le Cortex X925 d'ARM est dans la situation ivnerse. Il dispose de 4 AGU, dont deux sont banalisées et deux autres sont des AGU LOAD. Par contre, le cache du processeur peut faire 4 lectures ou 4 écritures par cycle. Ne parlons pas des premières microarchitectures AMD, de la K7, K8, K9 et K10, qui avaient trois AGU banalisées, mais un cache avec deux ports (un de lecture, un d'écriture). ===AGUs banalisées versus spécialisées=== Les AGUs banalisées et spécialisées ont chacune des avantages et inconvénients. La conception générale du processeur n'est pas exactement la même, entre les deux. Les circuits impactés sont les ''schedulers'' et la LSQ. La LSQ car elle receoit les adresses calculées, les ''schedulers'' car ils émettent les µops AGU. L'usage d'AGUs spécialisées impose des contraintes pour les ''schedulers''. Déjà, cela demande que la µops AGU dise si elle pour une lecture ou une écriture. Et le ''scheduler'' doit envoyer les µop AGU LOAD vers l'AGU LOAD, les µops AGU STORE vers l'AGU STORE. Il doit envoyer chaque µop dans le bon canal. L'usage d'AGUs banalisées simplifie grandement la conception du ''scheduler''. Il n'a pas à faire la différence entre µop AGU LOAD ou STORE, il voit juste des µops de calcul d'adresse, qu'il envoie dans les ports d'émission pour les AGUs. Pas besoin d'envoyer la bonne µop dans le bon canal : toutes les AGUs se valent. L'usage d'AGU spécialisées colle mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] Prenons maintenant un point de vue plus abstrait, plus théorique. Utiliser des AGU banalisées permet de calculer des adresses en avance, mais marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. La désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache). ==Le ''front-end'' des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Voyons ce qu'il en est. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ==Le ''back-end'' des processeurs superscalaires larges== Les processeurs superscalaires larges incorporent un grand nombre d'unités de calcul. Que ce soit pour des ALU entières, des AGUs ou des FPUs. Un processeur superscalaire actuel, de la moitié des années 2020, contient entre une dizaine et une vingtaine d'unités de calcul, AGU inclues. Par exemple, les processeurs Zen 2, sortis en 2019, avaient exactement 4 ports d'émission pour les ALU entières, 3 pour les AGUs, deux multiplieurs flottants et deux additionneurs flottants. La microarchitecture Gracemont d'Intel, datant approximativement des mêmes années, a 4 ports d'émission pour les ALUs, 2 pour les branchements, 4 pour les AGUs, 3 pour les FPUs. Et ces ports sont de plus partagés. Un grand nombre d'unités de calcul entraine de nombreuses complications. Les unités de calcul doivent être alimentées, ce qui demande d'ajouter des ports de lecture/écriture sur le banc de registres. De plus, le réseau de contournement doit interconnecter toutes les unités de calcul entre elles. Faites les comptes : avec 3 AGUs et 4 ports d'ALUs (partagés), cela fait 14 ports de lecture sur le banc de registres, et 7*7 possibilités d'interconnexions. C'est trop, beaucoup trop. Aussi, diverses optimisations visent à réduire ces couts, pour rendre un grand nombre d'ALU praticable. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> hej4cfzo1kecz635sjj79hyc54l14p6 774173 774172 2026-10-04T19:37:12Z Mewtow 31375 /* L'ajout de ports sur le cache */ 774173 wikitext text/x-wiki La double émission est la forme la plus simple de superscalarité, qui permet des gains en performance assez appréciables, même et surtout sans exécution dans le désordre. Au-delà, la triple et quadruple émission donnent des gains plus faibles, les rendements sont rapidement décroissants. La raison est qu'il est rare d'avoir des triplets ou quadruplets d'instructions indépendantes est très rare. Un processeur avec trois ou quatre pipelines séparés ne donnerait pas un très bon résultat, si on est regardant sur le budget en transistors. En pratique, il vaut mieux implémenter l'exécution dans le désordre que la triple/quadruple émission, le gain sera meilleur pour un cout en transistor similaire, si ce n'est inférieur. Par contre, les choses changent si on combine la triple/quadruple émission avec l'exécution dans le désordre. L'exécution dans le désordre permet d'exploiter la triple/quadruple émission correctement. Exécuter des µops dans le désordre marche de manière optimale si le processeur a mis en attente assez de µops. Trop peu de µops et il arrivera qu'aucune ne soit prête à être exécutée. La triple/quadruple émission permet de charger et décode trois instructions par cycle, ce qui permet d'accumuler rapidement des µops à exécuter. Évidemment, combiner les deux explose le budget en transistors, et il a fallu attendre que la loi de Moore fasse son effet pour pouvoir en profiter. La superscalarité change fondamentalement de sens avec l'exécution dans le désordre. Le concept de port d'émission change fortement, le concept de contrainte d’appariement perd son sens, il n'est plus possible de dire que le processeur dispose de N pipelines séparés, et j'en passe et des meilleures. La raison est la différence entre le nombre de ports de décodage et d'émission. Pour rappel, sur de nombreux processeurs superscalaires, l'unité d'émission a plus de ports d'émission que de ports de décodage, comme illustré ci-dessous. Et autant le surplus de ports d'émission est un poids mort sur les processeurs ''in-order'', autant ils sont exploités sur les processeurs ''out of order''. [[File:Processeur superscalaire avec un excès de ports d'émission.png|centre|vignette|upright=2|Processeur superscalaire avec un excès de ports d'émission]] ==Les ''schedulers'' des processeurs superscalaires== Prenons un processeur avec un ''sheduler'' centralisé, avec 4 ports de décodage et 6 ports d'émission. Le surplus de ports d'émission peut être exploité par le ''scheduler'' centralisé assez facilement. Si à un instant t, 6 µops sont prêtes à être exécutées, et que les condition idéales sont remplies, le ''sheduler'' peut émettre les 6 µops en même temps. Et cela n'est possible que parce que des µops ont été mises en attente. Il y a donc un gain en performances, mais il faut faire quelques précisions sur son origine. La largeur d'émission utilisée ne peut pas dépasser, ''en moyenne'', la largeur de décodage. Et c'est intuitif : un CPU qui décode 4 µops par cycle ne peut pas émettre 6 *ops par cycles en moyenne, sur le temps long. Il n'y a pas de µops en surplus qui sortent du vide. Par exemple, sur un processeur quadruple émission, si on analyse combien d'instructions sont émises par cycle, il peut y avoir des cycles où 6 µops sont émises, mais ce sera compensé par des périodes où seulement 2-3 µops sont émises, voire aucune. Les µops sont accumulées en cas de sous-utilisation, puis consommées en cas d'utilisation complète. Les deux périodes se compensent. Et c'est là que l'on peut comprendre d'où vient le gain en performance. Les cycles où les ports d'émission sont sous-utilisés existent systématiquement, que le processeur utilise l'exécution dans le désordre ou pas. Ils sont liés à la présence de dépendances de données, aux contraintes d’appariement, etc. Elles tendent à réduire la moyenne du nombre de µops émises par cycle. Par contre, ce sont les cycles de rattrapage, où les ports en surplus sont utilisés, qui font remonter la moyenne. Le gain en performance est donc lié à ces cycles de rattrapage, où les µops accumulées sont émises dans les ports en surplus. Et ils n'existent que sur les processeurs avec une file/fenêtre d'instruction. Toujours est-il que le surplus de ports d'émission fait qu'il est rentable de dupliquer certaines unités, qui ne l'étaient pas sur les processeurs ''in-order'', comme les AGUs ou les unités de branchement. Pour les unités de branchement, une subtilité complique le fait d'en installer plusieurs. Que faire quand deux branchements sont pris ? La solution la plus simple est de ne pas se trouver dans cette situation : on n'utilise qu'une seule unité de branchement et on n’exécute qu'un seul branchement par cycle. La majorité des processeurs superscalaires étroits font ainsi. Les processeurs superscalaires larges ont cependant deux unités de branchements, mais ils n'acceptent qu'un seul branchement pris à la fois. Si les deux branchements pris sont exécutés en même temps, le processeur n’exécute que le plus récent, dans l'ordre du programme. ===L'exemple du PA-RISC PA 8000=== Avant de rentrer dans la théorie, nous allons voir l'exemple d'un processeur superscalaire assez simple, le PA 8000, de HP, qui implémente le jeu d'instruction PA-RISC. Bien que très ancien, il implémentait l'exécution dans le désordre et le renommage de registre. Le peu d'informations que nous avons sur ce processeurs proviennent des documents "THE HP PA-8000 RISC CPU" par Ashok Kumar, et l'article de microprocessor report "PA-8000 Combines Complexity and Speed". Ils ne donnent malheureusement pas énormémment d'informations et je devrais être amené à faire quelques suppositions dans ce qui suit. Avant de commencer, parlons de détails sans lien avec la superscalarité. Ce processeur, comme beaucoup de processeurs PA-RISC, n'intégrait pas de cache de niveau L2 dans le processeur, qui était déporté en-dehors du CPU, sur la carte mère. Le processeur préférait utiliser ses transistors pour avoir un cache L1 assez large, quitte à ce qu'il soit plus lent. La lenteur du cache était prévue, le processeur compensait celle-ci en utilisant l'exécution dans le désordre, bien que ce ne soit pas parfait. Les CPU PA-RISC avant le PA 8000 utilisaient un pipeline à vagues ''wave pipeline'', ce qui était assez peu commun, mais le PA 8000 repasse à un pipeline synchrone classique. Le PA 8000 est un processeur quadruple émission, qui peut émettre, à chaque cycle, deux opérations et deux accès mémoire. Le renommage de registre utilise des registres de renommage séparés des registres architecturaux, comme c'était la mode à l'époque. Le budget en transistor faisait que c'était soit ça, soit le renommage dans le ROB, faute de mieux. Le processeur utilise une implémentation ''Read After Issue'', ce qui veut dire que les opérandes des instructions sont lues après l'émission, juste avant l'exécution. Le processeur peut aussi ''commit'' deux opérations et deux accès mémoire par cycle, ce qui revient à transférer les résultats des registres de renommage vers les registres architecturaux. L'exécution dans le désordre est prise en charge par une structure matérielle unique, qui ressemble à une fusion entre un ROB et un ''scheduler''. Et c'est là que je vais devoir faire des suppositions. Le document "THE HP PA-8000 RISC CPU" nomme cette structure : l''''''Instruction Reorder Buffer'''''. Il utilise pas moins de 850 000 transistors, et prend 20% de la taille du ''die''. L'article "THE HP PA-8000 RISC CPU" laisse fortement sous-entendre qu'il s'agit une structure unique, faite d'un seul tenant, mais l'article de microprocessor report dit le contraire par moment. Il est décrit comme un ''scheduler'', à savoir qu'il détecte les dépendances entre instruction et les émet. Il gère aussi la détection des mauvaises prédictions de branchement et peut annuler les instructions chargées à tort, en les retirant de le ''scheduler''. Il est aussi précisé que les instructions quittent cet ''Instruction Reorder Buffer'' dans l'ordre du programme. Elles ne peuvent en sortir que quand elles ont été exécutées et qu'elles n'ont causé aucune exception. Ce qui fait qu'il se comporte comme un ROB de ce point de vue là. Difficile de dire si l'''Instruction Reorder Buffer'' est un réel mélange entre un ROB et un ''scheduler'', ou s’il est réellement conçu avec deux structures séparées. Dans ce qui suit, je pars du principe que les deux structures sont séparées. Le processeur contient deux ''schedulers'' : un réservé aux µops mémoire (LOAD et STORE), l'autre pour les opérations entières/flottantes. Elles ont chacune 28 entrées, ce qui permet de mémoriser 56 µops au maximum. Les deux peuvent recevoir 4 µops par cycle de la part des décodeurs. Là encore, précisons que les choses ne sont pas claires pour ce qui est de la séparation. L'article de Microprocessor report dit que les deux sont physiquement séparées, et que cette séparation physique permet de simplifications (les opérations mémorisent des noms de registres plus court que les adresses demandées par les µops LOAD/STORE). Mais l'autre document cité laisse penser que l''''Instruction Reorder Buffer'' est une structure unique. À chaque cycle, le processeur peut émettre deux instructions de calcul et deux instructions mémoire. Les deux ''schedulers'' ont pour cela chacune deux ports d'émission. Les deux ports d'émission pour les µops mémoire sont identiques, et le cache L1 est un cache double port, ce qui permet d'exécuter deux lectures/écritures par cycle. Pour ce qui est des µops entières/flottantes, le processeur a dupliqué toutes ses unités de calcul : il a deux ALU entières, deux ''barrel shifters'', deux FPU de type FMAC, et deux unités de division flottante. Vraisemblablement, il y a deux ports d'émission, chacun étant connecté à une unité de chaque type. : S'il n'utilisait pas l'exécution dans le désordre, ce processeur aurait donc quatre pipelines : deux pipelines entiers identiques et deux pipelines mémoire. Et il aurait une superscalarité presque parfaite s'il était un CPU à double émission ! À chaque cycle, le ''scheduler'' émet les deux µops les plus anciennes qui sont prêtes à être exécutées. La documentation dit que le ''scheduler'' émet "la plus vielle instruction paire, et la plus vielle instruction impaire", ce qui sous-entend que le ''scheduler'' est segmentée en deux. C'est précisément la logique d'éveil et d'émission qui est segmentée, une partie gérant la moité de le ''scheduler'', une seconde logique d'éveil/''schedule'' gérant le reste. le ''scheduler''s n'implémente donc pas une vraie double émission, elle est partitionnée pour permettre d'émettre deux µops en même temps. L'avantage est une économie de transistors manifeste. Un inconvénient est que si les µops prêtes sont toutes dans la même moitié, une seule µops sera émise par cycle. Mais avec 2*14 entrées, le cas est assez rare pour que l'économie de transistors en vaille la peine. [[File:PA 8000 - microarchitecture.png|centre|vignette|upright=2|PA 8000 - microarchitecture]] ===La superscalarité avec un ''scheduler'' centralisé=== Un ''scheduler'' centralisé peut avoir plusieurs ports d'émission, à condition que sa logique d'éveil et de ''schedule'' soient adaptées. Utiliser un ''scheduler'' unique avec 4 ou 5 ports est possible. Cependant, au-delà, avoir trop de ports d'émission rend le ''scheduler'' très complexe et assez "lent". Et mine de rien, c'est là un compromis important : à quoi bon augmenter le nombre de ports d'émission si c'est pour que ce soit compensé par une fréquence CPU plus faible ? Par contre, réduire le nombre de ports augmente la ''port contention'', surtout si on a mal réparti les ALU. Il faut donc trouver un compromis entre réduire le nombre de ports d'émission d'un côté, réduire la ''port contention'' de l'autre. La différence entre largeur de décodage et d'émission a un impact assez fort sur le banc de registre, et précisément sur son nombre de ports de lecture. Le banc de registre a besoin de plus de ports de lecture avec une fenêtre d’instruction qu'avec des stations de réservation. Rappelons la terminologie utilisée dans ce cours. Les fenêtres d’instruction se contentent de mémoriser la micro-opération et quelques bits pour détecter la disponibilité des opérandes. Par contre, les stations de réservations mémorisent aussi les opérandes des instructions. Et cette différence a une influence le nombre de ports de lecture du banc de registre. Supposons que toutes les instructions sont dyadiques, à deux opérandes. Comptons le nombre de ports nécessaires pour alimenter les unités de calcul, sans utiliser d'arbitre de banc de registre. Avec un ''scheduler'', il faut le double du nombre de ports d'émission. Mais avec des stations de réservation, il faut le double des ports de décodage. La raison est que les registres sont lus après émission avec un ''scheduler'', avant avec des stations de réservation (sous-entendu, après le décodage). Or, comme dit plus haut, les processeurs superscalaires larges ont souvent plus de ports d'émission que de décodage. La superscalarité modifie aussi les circuits dédiés à l'exécution dans le désordre. Elle modifie les unités de renommage de registre, le ROB et les autres structures liées à l'exécution dans le désordre, au sens large. Aucune structure n'est épargnée. Elles ont tous des ports de lecture/écriture en plus, afin d'encaisser l'émission, le ''commit'' ou le renommage simultané de plusieurs µops. Par exemple, le ROB doit être modifié pour émettre et terminer plusieurs instructions en même temps, en ajoutant des ports de lecture/écriture. Pour un CPU superscalaire à 8 voies, on se dit que le ROB peut retirer 8 µops par cycle maximum. Mais certains processeurs récents ont un ROB qui peut retirer le double ! Un exemple est celui des CPU de micro-architecture Skymont, qui peuvent émettre 8 µops par cycle, mais en retirer 16. Quelques CPU de marque AMD étaient aussi dans ce cas. L'avantage est que cela permet de réduire la taille du ROB, du banc de registre et de quelques autres structures matérielles liées à l'exécution dans le désordre. Le gain sur-compense le cout en transistor nécessaire pour retirer 16 µops par cycle. ===La superscalarité à ''schedulers'' décentralisés=== Les processeurs précédents utilisaient un ''scheduler'' centralisé. Passons maintenant aux ''schedulers'' décentralisés. Pour rappel, sur ces processeurs, les µops sont émises en deux phases. Une première phase de ''dispatch'' qui envoie les µops vers le ''scheduler'' adéquat, sans détecter les dépendances de données. La seconde phase de ''schedule'' gère les dépendances de données, et met en attente les µops tant qu'elles ne sont pas résolues. Et à ce petit jeu, il y a une différence entre le nombre de µops qui sont ''dispatch par cycle'' et celles qui sont ''schedule''. Dit autrement, la largeur d'émission est scindée en une '''largeur de ''dispatch''''' et une '''largeur de ''schedule'''. Pour le comprendre, rien de mieux que d'étudier quelques exemples. Les exemples qui vont suivre sont les processeurs alpha 21264, MIPS R10000 et AMD Athlon. Les deux premiers sont des processeurs quadruple émission. : Pour rappel, nous parlerons d'AGU pour désigner les unités de calcul d'adresse, et de '''µop AGU''' pour parler des µops de calcul d'adresse. Un exemple est celui de l'alpha 21264. Il pouvait ''dispatch'' 4 µops et en ''schedule'' 6 : 2 µops AGU, deux µops entières et 2 µops flottantes. Son ''front-end'' pouvait charger et décoder 4 instructions par cycles, qui étaient souvent décodées en autant de µops. Les µops étaient alors réparties dans deux ''schedulers'' : un pour les opérations flottantes, un autre pour les µops entières/AGU. Le ''scheduler'' flottant avait deux ports d'émission, l'un relié à l'additionneur flottant, l'autre au multiplieur flottant. Le ''scheduler'' entier avait quatre ports d'émission, pour alimenter deux ALU entières et deux AGUs. Le processeur pouvait donc émettre deux µops entières et deux µops AGU. [[File:Alpha 21264.png|centre|vignette|upright=2.5|Alpha 21264]] Le MIPS R10000 décodait 4 instructions par cycle et produisait jusqu'à 4 µops, qui étaient redistribuées sur trois ''schedulers'' : une LSQ pour les µops mémoire, un pour les instructions entières, un pour les opérations flottantes. Chaque ''scheduler'' pouvait mémoriser 16 µops. Ils acceptaient 4 µops par cycle, ce qui fait que processeur pouvait ''dispatch'' 4 µops entières, ou 4 µops flottantes, ou 4 µops mémoire, ou un mélange de deux ou trois types de µops. Le ''scheduler'' entier pouvait émettre 2 µops, grâce à deux ALU entières (dont une avait un multiplieur entier). Le ''scheduler'' flottant pouvait émettre trois µops flottantes : une addition en même temps qu'une multiplication/division flottante et qu'un branchement conditionnel flottant. Le processeur ne pouvait émettre qu'une µop AGU à la fois, sur une AGU/unité mémoire unique. L'adresse était envoyée à la LSQ, qui faisait les accès mémoire de manière asynchrone. Les branchements et NOPs étaient exécutés dans une unité de branchement distincte. En tout, cela fait ''schedule'' 6 µops pour 4 µops au ''dispatch''. [[File:R 10000.png|centre|vignette|upright=2.5|MIPS R10000, microarchitecture.]] Voyons maintenant la microarchitecture K7 d'AMD, utilisée sur les processeurs Athlon dans les années 2000. Le processeur chargeait et décodait 3 instructions par cycle, ce qui en fait un processeur triple émission. Elle avait un ''scheduler'' entier séparé du ''scheduler'' flottant. Les deux pouvaient émettre trois µops par cycle, ce qui fait 6 µops par cycle au total. Le ''scheduler'' entier avait beau avoir trois ports d'émission, il alimentait trois ALU entières et trois AGU (calculs d'adresse). La raison est que les ports d'émission étaient partagés : chaque port était relié à une ALU et une AGU. [[File:Athlon.png|centre|vignette|upright=2.5|AMD K7, microarchitecture.]] ===La superscalarité à ''schedulers'' totalement décentralisés=== Prenons un cas extrême de fenêtre d'instruction décentralisé : celui où chaque ALU entière a sa propre station de réservation. En clair : une fenêtre d'instruction par ALU/FPU ! On parle de '''''schedulers'' totalement décentralisés'''. De tels ''schedulers'' sont parfois appelés des ''stations de réservation'', mais nous avons utilisé ce terme d'une autre manière dans ce cours (pour désigner les ''schedulers'' utilisant la lecture avant émission). Vous vous dites que ce cas est un peu trop extrême pour être réaliste, mais il n'en est rien. C'est même très courant sur les architectures basse consommation, et sur pas mal de CPU x86 modernes. Les CPU AMD anciens comme modernes tendent à utiliser cette organisation. Un exemple st celui des microarchitectures K5 d'AMD, leur toute première microarchitecture Elle utilisait des ''schedulers'' totalement décentralisés, sauf pour l'unité mémoire, qui avait deux unités de calcul d'adresse (mais une seule LSQ). [[File:AMDK5Diagram.png|centre|vignette|upright=2.5|Microarchitecture K5 d'AMD.]] Une telle organisation entraine une légère perte de performance, comparé à un ''scheduler'' centralisé ou partiellement décentralisé. Et cela peut se comprendre par un exemple. Imaginez un processeur avec deux ALU, donc deux stations de réservation. Il arrivera qu'une station de réservation ait plusieurs µops prêtes, alors que l'autre en ait zéro. Dans une telle situation, une seule µop sera émise au cycle suivant, vu que chaque station de réservation a un seul port d'émission vers une seule ALU entière. Par contre, avec un ''scheduler'' centralisé double port, on aurait pu émettre les deux µops en même temps sur deux ALUs séparées. La perte de performance est donc liée au fait que les µops '''''prêtes''''' sont mal réparties dans les différentes stations de réservation. Il arrivera que des µops prêtes s'accumulent dans une station de réservation, alors que d'autres seront pleines, ou du moins sous-utilisées. Et ce n'est pas quelque chose qui peut être réglé à l'étape de ''dispatch'', car on ne sait pas encore quand les µops auront leurs opérandes de disponibles. [[File:Comparaison entre une station de réservation totalement décentralisée et une autre qui ne l'est pas.png|centre|vignette|upright=2.5|Comparaison entre une station de réservation totalement décentralisé et une autre qui ne l'est pas.]] Par contre, décentraliser totalement les ''schedulers'' entraine aussi un gain considérable en termes de consommation énergétique. Et c'est la raison pour laquelle les ''schedulers''s totalement décentralisés sont utilisées sur les architectures basse consommation. La perte de performance ne peut pas s'expliquer clairement à ce stade du cours, car il nous faudrait parler d'émission superscalaire. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Le gain en matière d'énergie vient de l'économie de circuits, qui est assez drastique. Je rappelle qu'une station de réservation a besoin, pour N instructions, d'environ N^2 circuits (des comparateurs, notamment). Prenons une station de réservation centralisé de 64 entrées et comparons avec 8 stations de 8 entrées chacune. Les deux donneront 64 entrées, mais la première aura un cout en circuit proportionnel à 64², alors que le second aura un cout en circuit de 8 *(8²). Entre les deux, le choix est clair : la seconde solution est plus économe. D'où l'utilisation de cette solution sur les microarchitectures basse consommation. ==L'unité de renommage superscalaire== Avec la superscalarité, l'unité de renommage de registre doit gérer le cas où des instructions consécutives ont des dépendances de registre. Par exemple, prenons un processeur à double émission, qui renomme deux instructions consécutives. Si elles ont une dépendance de registre, le renommage de la seconde instruction dépend du renommage de la première. La première doit être renommée et le résultat du renommage est utilisé pour renommer la seconde, ce qui empêche d'utiliser deux unités de renommage séparées. Pour cela, elle renomme les registres sans tenir compte des dépendances, pour ensuite corriger le résultat. [[File:Unité de renommage superscalaire.png|centre|vignette|upright=2|Unité de renommage superscalaire.]] Seules les dépendances lecture-après-écriture doivent être détectées, les autres étant supprimées par le renommage de registres. Repérer ce genre de dépendances se fait assez simplement : il suffit de regarder si un registre de destination d'une instruction est un opérande d'une instruction suivante. [[File:Détection des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Détection des dépendances sur un processeur superscalaire.]] Ensuite, il faut corriger le résultat du renommage en fonction des dépendances. Si une instruction n'a pas de dépendance avec une autre, on la laisse telle quelle. Dans le cas contraire, un registre opérande sera identique avec le registre de destination d'une instruction précédente. Dans ce cas, le registre opérande n'est pas le bon après renommage : on doit le remplacer par le registre de destination de l'instruction avec laquelle il y a dépendance. Cela se fait simplement en utilisant un multiplexeur dont les entrées sont reliées à l'unité de détection des dépendances. On doit faire ce replacement pour chaque registre opérande. [[File:Correction des dépendances sur un processeur superscalaire.png|centre|vignette|upright=2|Correction des dépendances sur un processeur superscalaire.]] ==L'unité mémoire/LSQ des processeurs superscalaires== Les processeurs superscalaires larges travaillent sur des paquets de 8 à 10 instructions, il est plus fréquent d'avoir plusieurs accès mémoire par paquet. Dans ces conditions, il est utile d'émettre plusieurs micro-opérations mémoire en même temps, et de les exécuter en parallèle.. On parle alors d''''émission multiple des accès mémoire''', ou encore d'''''''émission mémoire multiple'''. Mais avant de poursuivre, précisons que les processeurs superscalaires disposent tous d'AGUs (''Adress Generation Unit'') séparées de l'unité mémoire. Il y a donc une différence entre les µops mémoire proprement dites et les µops AGU qui font des calculs d'adresse. L'unité mémoire contient une ''Load-store queue'', qui fait l'interface entre les AGUs et le cache de données. Elle récupère les adresses calculées par les AGUs et décide quelles µops mémoire peuvent s’exécuter. Et cette séparation fait que, quand on parle d'émission mémoire multiple, cela peut signifier deux choses. ===L'excès d'AGU avec une LSQ simple=== Il est possible, mais rare, d'avoir plusieurs AGUs sans pour autant que le processeur puisse faire plusieurs accès mémoire par cycle. Les adresses sont accumulés dans la ''load/store queue'', qui sont ensuite traités un par un par le cache. Un exemple est celui des processeurs AMD de micro-architecture K5, les tout premiers CPU AMD. Ils pouvaient calculer deux adresses simultanément, mais ne pouvaient faire qu'un seul accès au cache par cycle. Nous désignerons cette situation un '''excès d'AGU''', sous-entendu "excès par rapport au cache". Le cas précédent est tout de même rare. Utiliser plusieurs AGUs avec un cache simple port augmente légèrement les performances, car les adresses sont calculées en avance, ce qui permet à la désambiguïsation mémoire de fonctionner mieux. Mais le cout en circuit n'en vaut pas la chandelle. Il faut non seulement dupliquer les ALUs, mais aussi, et surtout, ajouter des ports d'entrée sur la LSQ, pour recevoir les adresses calculées. Et le cout en circuit est conséquent, surtout comparé aux gains en performance obtenus. ===L'ajout de ports sur le cache=== [[File:Processeur non-superscalaire basique 02.png|vignette|upright=1|Processeur non-superscalaire basique]] Le cas le plus fréquent utilise non seulement plusieurs AGUs, mais aussi de quoi faire plusieurs accès mémoire par cycle. Le processeur doit pour cela supporter le ''parallélisme mémoire'', à savoir qu'il peut faire plusieurs lectures/écritures par cycle, dans le cache de données. Pour cela, le cache est rendu multiport. L'implémentation la plus simple utilise deux ports sur le cache de données : un port de lecture et un port d'écriture. Elle permet de faire en parallèle une lecture et une écriture dans le cache. Mais attention, la différence entre µop AGU et µop mémoire vient encore faire des siennes. Si les doubles lectures et double écritures sont interdites au niveau du cache, ce n'est pas forcément le cas avant la LSQ. Le processeur peut très bien émettre deux calculs d'adresse pour deux STORE en même temps. Simplement, au moins l'un d'entre eux sera mis en attente dans la LSQ, car le cache ne suit pas derrière. Et cela nous amène à un concept très important : la spécialisation des unités de calcul d'adresse. Un processeur superscalaire peut utiliser des '''AGUs banalisées''', à savoir des unités de calcul qui font des calcul d'adresse sans se soucier de savoir si l'adresse est celle d'une lecture ou d'une écriture. A l'opposé, le processeur peut aussi utiliser des '''AGUs spécialisées''', avec des ''AGU LOAD'' qui ne gèrent que les calculs d'adresse pour les µops LOAD, et des ''AGU STORE'' qui font les calculs d'adresse pour les écritures. Les deux solutions sont utilisées sur les processeurs Intel et AMD modernes. [[File:Emissim multiple des µops mémoire.png|vignette|upright=1|Emission multiple des µops mémoire avec des AGUs spécialisées.]] S'il utilise des AGUs spécialisées, le processeur a en général deux fois plus d'AGU pour les lectures que d'AGU pour les écritures. Il faut dire qu'un programme fait en moyenne deux lectures pour une écriture. Les premiers CPU superscalaires x86 avaient généralement deux AGU LOAD pour une AGU STORE. Pour les processeurs superscalaires actuels, qui sont vraiment larges, on est à 5 AGU LOAD pour 3 AGU STORE. Les processeurs Intel tendent à utiliser des AGU spécialisées, alors qu'AMD a plus tendance à utiliser des AGU banalisées. Pour donner un exemple, la microarchitecture Zen 5 d'AMD utilise 4 AGU banalisées, alors que le cache a deux ports de lecture et deux ports banalisés (capables de faire lectures et écritures). Les deux ne collent pas, mais la LSQ fait l'interface entre les deux. Elle peut absorber les adresses d'écriture calculées en trop. A l'inverse, le Cortex X925 d'ARM est dans la situation ivnerse. Il dispose de 4 AGU, dont deux sont banalisées et deux autres sont des AGU LOAD. Par contre, le cache du processeur peut faire 4 lectures ou 4 écritures par cycle. Ne parlons pas des premières microarchitectures AMD, de la K7, K8, K9 et K10, qui avaient trois AGU banalisées, mais un cache avec deux ports (un de lecture, un d'écriture). ===AGUs banalisées versus spécialisées=== Les AGUs banalisées et spécialisées ont chacune des avantages et inconvénients. La conception générale du processeur n'est pas exactement la même, entre les deux. Les circuits impactés sont les ''schedulers'' et la LSQ. La LSQ car elle receoit les adresses calculées, les ''schedulers'' car ils émettent les µops AGU. L'usage d'AGUs spécialisées impose des contraintes pour les ''schedulers''. Déjà, cela demande que la µops AGU dise si elle pour une lecture ou une écriture. Et le ''scheduler'' doit envoyer les µop AGU LOAD vers l'AGU LOAD, les µops AGU STORE vers l'AGU STORE. Il doit envoyer chaque µop dans le bon canal. L'usage d'AGUs banalisées simplifie grandement la conception du ''scheduler''. Il n'a pas à faire la différence entre µop AGU LOAD ou STORE, il voit juste des µops de calcul d'adresse, qu'il envoie dans les ports d'émission pour les AGUs. Pas besoin d'envoyer la bonne µop dans le bon canal : toutes les AGUs se valent. L'usage d'AGU spécialisées colle mieux avec l'organisation interne de la LSQ. Les processeurs superscalaires modernes utilisent une LSQ scindée en deux : une file de µops LOAD et une file de µops STORE. Si on utilisait deux AGU banalisées, on devrait connecter chaque AGU à la fois à la file de µops LOAD et à la file de µops STORE. Avec une AGU pour les lectures et une AGU pour les écritures, le nombre de connexions est réduit. Il suffit de connecter l'AGU pour les lectures à la file de µops LOAD et l'AGU pour les écritures à la file de µops STORE. [[File:Double émission avec le Load Ordering, Store Ordering.jpg|centre|vignette|upright=2.5|Double émission avec le Load Ordering, Store Ordering]] Prenons maintenant un point de vue plus abstrait, plus théorique. Utiliser des AGU banalisées permet de calculer des adresses en avance, mais marche mal avec le fait que les lectures sont bien plus nombreuses que les écritures. La désambiguïsation mémoire a surtout besoin de connaitre à l'avance les adresses des écritures, pour fonctionner au mieux. or, avec des AGU non-spécialisées, il peut y avoir des problèmes. Elles ont l'avantage de pouvoir calculer plusieurs adresses d'écritures par cycle. Sauf qu'en pratique, les lectures étant plus nombreuses que les écritures, les AGU banalisées seront saturées de calculs d'adresse pour des lectures, avec le risque que les adresses des écritures passent au second plan. Avoir au minimum une AGU STORE spécialisée réduit fortement le problème (plus précisément une AGU STORE par port d'écriture du cache). ==Le ''front-end'' des processeurs superscalaires larges== Les processeurs superscalaires larges ont des optimisations spécifiques, qui n'ont pas de sens sur les processeurs superscalaires étroits. Dans les grandes lignes, elles se classent en deux types. Les premières permettent de charger/décoder plus de 5-10 instructions simultanément. Le problème est alors que les branchements viennent mettre leur grain de sel et qu'il faut en tenir compte. Voyons ce qu'il en est. ===L'étape de chargement d'un CPU superscalaire large=== Charger plusieurs instructions pose de nombreux problèmes sur les processeurs superscalaires larges, qui émettent de 5 à 10 instructions simultanées. Pour rappel, la stratégie basique pour charger N instructions est de charger un bloc de N instructions consécutives. Les instructions après un branchement ne sont pas décodées ni exécutées, ce qui entraine une petite baisse de performance. Pour un N petit, la perte de performance est mineure. Mais pour un N large, elle devient suffisamment importante pour que la conception du CPU doit alors être repensée de fond en comble. Les analyses statistiques disent qu'il y a environ un branchement tous les 4 à 6 instructions. Un CPU a donc tendance à décoder des paquets de 3 à 5 instructions consécutives, avant qu'un branchement casse la dynamique. Or, cela pose des problèmes pour utiliser plusieurs décodeurs. L'implémentation basique, qui charge N instructions consécutives pour alimenter N décodeurs, fonctionne donc jusqu'à 4/6 décodeurs. Au-delà de 5 instructions, un CPU superscalaire doit être adapté pour gérer les branchements dans les paquets d'instruction qu'il émet. Le nombre de 5 instructions ne sort donc pas de nulle part. La première solution utilise un cache de µops, avec un ''front-end'' capable de charger/décoder 4/5 instructions à la fois, pas plus. L'idée est que la majorité des instructions proviennent du cache de µops (plus de 80%), ce qui fait qu'on peut émettre environ 10 instructions à partir du cache de µops, pas besoin d'avoir beaucoup de décodeurs. Les performances sont cependant dégradées lors d'un défaut de cache de µops. Les instructions doivent alors être chargées depuis le cache d'instruction, par paquets de 4-5, puis sont décodées par 4-5 décodeurs, ce qui ne permet pas d’alimenter les unités de calcul à plein régime. La solution précédente joue sur la distinction entre pots de décodage et d'émission. Elle permet d'émettre un grand nombre de µops sans pour autant charger un grand nombre d'instructions en même temps. En quelque sorte, ce n'est qu'une méthode de contournement, le CPU superscalaire reste de type étroit. Mais il est aussi possible d'utiliser un '''''front-end large''''', capable de charger une bonne dizaine d'instructions en même temps. La seule difficulté est de tenir compte de la présence de branchements. Pour rappel, avec un ''front-end'' étroit, les instructions situées après le premier branchement pris sont annulées, elles ne sont pas décodées ni exécutées. [[File:Fetch sur un processeur superscalaire avec prediction de branchements.png|centre|vignette|upright=2|Fetch sur un processeur superscalaire avec prédiction de branchements.]] Un ''front-end'' large charge les instructions de destination du branchement et les placent à sa suite. Ils chargent deux blocs à la fois et les fusionnent en un seul qui ne contient que les instructions présumées utiles. [[File:Cache d'instructions autoaligné.png|centre|vignette|upright=2|Cache d'instructions auto-aligné.]] Mais cela demande de charger deux blocs de mémoire en une fois, ce qui demande un cache d'instruction multiports. Il faut aussi ajouter un circuit pour assembler plusieurs morceaux de blocs en un seul : le fusionneur (''merger''). En pratique, cette solution demande d'ajouter un second port de lecture au cache, pour un cout en hardware important, avec un impact négatif sur le temps d'accès au cache. Et il faut rajouter le fusionneur, qui prend un étage de pipeline à lui tout seul. Et encore, cette solution ne gère pas le cas où un bloc contient plusieurs branchements ! [[File:Implémentation d'un cache d'instructions autoaligné.png|centre|vignette|upright=2|Implémentation d'un cache d'instructions auto-aligné.]] Les exemples précédents partent du principe qu'il n'y a qu'un seul branchement dans un bloc. Mais quand les blocs deviennent assez longs, il est fréquent d'avoir plusieurs branchements dans un bloc. Et il est même possible d'avoir plusieurs branchements pris. Dans un bloc de 8-10 instructions, ce n'est pas rare, et les CPU superscalaires à 8-10 voies sont la règle dans les CPU des années 2020. Pour gérer ce cas, il faut ajouter des ports supplémentaires au cache d'instruction et adapter le fusionneur, pour qu'il puisse fusionner trois ou quatre blocs. De plus, il faut utiliser une unité de prédiction de branchement capable de ''prédire plusieurs branchements par cycle''. ===Le décodage superscalaire large=== Le décodage des instructions n'est pas censé dépendre de l'implémentation du chargement. En théorie, une fois plusieurs blocs d'instruction fusionnés, on peut décoder les instructions en parallèle. Cependant, certains processeurs effectuent le décodage en tenant compte de la manière dont les branchements découpent les blocs. Les processeurs Atom d'Intel utilisent une solution de ce type, qui couple prédiction de branchement et décodage parallèle. Ils chargent/décodent des paquets de 3 instructions consécutives, chaque paquet étant séparé du précédent par un branchement. Pour cela, en plus d'avoir un fusionneur adapté, ils regroupent les décodeurs en ''clusters'', chacun regroupant 3 décodeurs, et pouvant décoder trois instructions consécutives. Les micro-architectures Tremont et Crestmont avaient deux ''clusters'' (6 décodeurs au total), la micro-architecture Darkmont en intègre trois (9 décodeurs au total). Avec deux ''clusters'', le fonctionnement est le suivant. Si le code est composé d'une suite d'instruction sans branchements, seul le premier ''cluster'' est utilisé, limitant le décodage à trois instructions par cycle. Par contre, si l'une des trois instructions décodée est un branchement, alors le second ''cluster'' décode les trois instructions situées après ce branchement, à partir de l'adresse de destination. Avec un troisième ''cluster'', c'est la même chose, sauf qu'il faut qu'il y ait un branchement dans les trois instructions décodées par le second ''cluster''. Mais comment faire pour savoir quels décodeurs activés, quels ''clusters'' sont actifs ? C'est la prédiction de branchement qui commande. Avec deux ''clusters'', elle détermine si un branchement est présent dans les trois instructions à charger, et quelle est son adresse de destination. Sans branchement, elle prévient les décodeurs et les trois instructions chargées sont décodées dans un ''cluster'' libre. Si elle prédit un branchement, elle charge les instructions adéquates, et active les deux ''clusters''. Avec trois branchements, c'est le même mécanisme, sauf que l'unité de prédiction de branchement doit être capable de prédire deux branchements par cycle. [[File:Front-end découplé de la microarchitecture Tremont.png|centre|vignette|upright=3|Front-end découplé de la microarchitecture Tremont.]] Il faut noter que les décodeurs d'un ''cluster'' sont des décodeurs simples, sans microcode. Sur les processeurs Tremont et Crestmont, le microcode était unique et était partagé entre tous les ''clusters''. Un seul ''cluster'' pouvait décoder une instruction complexe avec le microcode, l'autre devait se limiter à des instructions simples uniquement. Depuis Skymont, le microcode est partiellement dupliqué, avec un microcode par ''cluster''. Le cout en hardware peut paraitre prohibitif, pour un gain en performance faible. Sauf que c'est oublier que ce sont des processeurs Atom, sur lesquels beaucoup d'instructions sont microcodées. Les processeurs Atom ont un budget en transistor limité, ce qui fait qu'il est préférable pour eux d'implémenter des instructions en microcode que d'utiliser des unités de calcul dédiées ou des circuits de décodage complexes. Pour limiter la casse, le microcode n'est que partiellement dupliqué. Seul le microcode pour les instructions fréquente est dupliqué. Il y a un microcode secondaire pour certaines instructions, qui est lui unique et partagé par tous les ''clusters''. ==Le ''back-end'' des processeurs superscalaires larges== Les processeurs superscalaires larges incorporent un grand nombre d'unités de calcul. Que ce soit pour des ALU entières, des AGUs ou des FPUs. Un processeur superscalaire actuel, de la moitié des années 2020, contient entre une dizaine et une vingtaine d'unités de calcul, AGU inclues. Par exemple, les processeurs Zen 2, sortis en 2019, avaient exactement 4 ports d'émission pour les ALU entières, 3 pour les AGUs, deux multiplieurs flottants et deux additionneurs flottants. La microarchitecture Gracemont d'Intel, datant approximativement des mêmes années, a 4 ports d'émission pour les ALUs, 2 pour les branchements, 4 pour les AGUs, 3 pour les FPUs. Et ces ports sont de plus partagés. Un grand nombre d'unités de calcul entraine de nombreuses complications. Les unités de calcul doivent être alimentées, ce qui demande d'ajouter des ports de lecture/écriture sur le banc de registres. De plus, le réseau de contournement doit interconnecter toutes les unités de calcul entre elles. Faites les comptes : avec 3 AGUs et 4 ports d'ALUs (partagés), cela fait 14 ports de lecture sur le banc de registres, et 7*7 possibilités d'interconnexions. C'est trop, beaucoup trop. Aussi, diverses optimisations visent à réduire ces couts, pour rendre un grand nombre d'ALU praticable. ===Le contournement sur les processeurs superscalaires larges=== Pour rappel, la technique du contournement (''register bypass'') permet au résultat d'une instruction d'être immédiatement utilisable en sortie de l'ALU, avant même d'être enregistré dans les registres. Implémenter la technique du contournement demande d'utiliser des multiplexeurs pour relier la sortie de l'unité de calcul sur son entrée si besoin. Il faut aussi des comparateurs pour détecter des dépendances de données. [[File:Pipeline Bypass.png|centre|vignette|upright=1|Pipeline Bypass]] La sortie de chaque ALU doit être reliée aux entrées de toutes les autres, avec les comparateurs qui vont avec. Sur les processeurs ayant plusieurs d'unités de calculs, cela demande beaucoup d’interconnexions et de comparateurs. Pour N unités de calcul, cela demande 2 * N² interconnexions, implémentées avec 2N multiplexeurs de N entrées chacun. Si c'est faisable pour 2 ou 3 ALUs, la solution est impraticable au-delà. Non seulement ce serait un enfer à câbler, mais les fils seraient tellement longs que la fréquence du processeur en prendrait un coup. Notez que cela vaut pour les processeurs superscalaires, mais aussi pour tout processeur avec beaucoup d'unités de calcul. En théorie, un CPU à exécution dans le désordre, non-superscalaire, mais avec beaucoup d'unités de calcul, fait face au même problème. En pratique, avoir une dizaine d'ALU implique processeur superscalaire à exécution dans le désordre. D'où le fait qu'on parle du problème maintenant. La seule solution praticable est de ne pas relier toutes les unités de calcul ensemble. À la place, on préfère regrouper les unités de calcul dans différents '''agglomérats''' ('''cluster'''). Le contournement est alors possible entre les unités d'un même agglomérat, mais pas entre agglomérats différents. Évidemment, certaines possibilités de contournement sont perdues, mais c'est compensé par un gain en fréquence et un cout en circuit/fils réduit. C'est un bon compromis, ce qui explique que presque tous les processeurs modernes l'utilisent. Les rares exceptions sont les processeurs POWER 4 et POWER 5, qui ont préféré se passer de contournement pour garder un processeur très simple et une fréquence élevée. ===Le banc de registre des processeurs superscalaires larges=== Pour émettre deux opérations entières simultanément, il faut lire deux fois plus d'opérandes. Et il faut aussi écrire deux résultats dans les registres, en même temps. Pour cela, on double les ports du banc de registre. En général, il faut au maximum 2 ports de lecture et un port d'écriture par unité de calcul. Le problème, c'est que plus un banc de registres a de ports, plus il utilise de circuits, est compliqué à concevoir, consomme de courant et chauffe. Avec plusieurs dizaines d'unités de calcul différentes, ajouter autant de ports impacterait la fréquence du banc de registres et réduirait les performances, voire est totalement impraticable à cause des contraintes thermiques. Les processeurs superscalaires larges doivent donc trouver des solutions. La première solution utilise un banc de registre unique, mais n'utilise pas autant de ports que le pire des cas le demanderait. Le processeur détecte quand il n'y a pas assez de ports pour servir toutes les micro-opérations. Quand ça arrive, l'unité d'émission émet assez de micro-opérations pour exploiter les ports disponibles, mais met en attente les micro-opérations en trop, le temps que les ports se libèrent. Pour cela, le banc de registre est géré par un circuit d'arbitrage spécialisé, intégré à l'unité d'émission, l’'''arbitre du banc de registres''' (''register file arbiter''). Une autre solution est de dupliquer le banc de registres en plusieurs exemplaires qui contiennent exactement les mêmes données, mais avec moins de ports de lecture/écriture. Un exemple est celui des processeurs POWER, Alpha 21264 et Alpha 21464. Sur ces processeurs, le banc de registre est dupliqué en plusieurs exemplaires, qui contiennent exactement les mêmes données. Les écritures dans les registres sont envoyées à tous les bancs de registres, afin de garantir que leur contenu est identique. Chaque exemplaire a exactement deux ports de lecture. La conception du processeur est simplifiée, que ce soit au niveau du câblage, que de la conception des bancs de registres. Une dernière solution découpe le banc de registres en plusieurs bancs de registres plus petits, avec un réseau d'interconnexions pour échanger des données entre bancs de registres. Dans la plupart des cas, cette séparation est invisible du point de vue de l'assembleur et du langage machine. Le processeur se charge de transférer les données entre bancs de registres suivant les besoins. Sur d'autres processeurs, les transferts de données se font via une instruction spéciale, souvent appelée ''COPY''. [[File:Banc de registres distribué.png|centre|vignette|upright=2|Banc de registres distribué.]] Sur de certains processeurs, un branchement est exécuté par une unité de calcul spécialisée. Or les registres à lire pour déterminer l'adresse de destination du branchement ne sont pas forcément dans le même agglomérat que cette unité de calcul. Pour éviter cela, certains processeurs disposent d'une unité de calcul des branchements dans chaque agglomérat. Dans les cas où plusieurs unités veulent modifier le ''program counter'' en même temps, un système de contrôle général décide quelle unité a la priorité sur les autres. Mais d'autres processeurs fonctionnent autrement : seul un agglomérat possède une unité de branchement, qui peut recevoir des résultats de tests de toutes les autres unités de calcul, quel que soit l’agglomérat. ==La macro-fusion : une optimisation du décodage parallèle== Les processeurs superscalaires supportent la technique dite de '''macro-fusion''', qui permet de fusionner deux-trois instructions consécutives en une seule micro-opération. Par exemple, il est possible fusionner une instruction de test et une instruction de saut en une seule micro-opération de branchement. Il s'agit là de l'utilisation la plus importante de la macro-fusion sur les processeurs x86 modernes. La macro-fusion est utilisée pour fusionner un test et un saut en un branchement unique. L'optimisation est présente sur la plupart des CPU superscalaires modernes. Les CPU x86 l'implémentent, mais aussi quelques CPU ARM et les processeurs Apple de série M (M1 à M5, et potentiellement plus quand ils seront sortis). La fusion des instructions se fait lors du décodage des instructions, grâce à la coopération des décodeurs. Elle marche systématiquement si l'instruction de test et le saut sont consécutives, qu'elles forment bien une paire d'instruction. Par contre, insérer une instruction entre les deux empêche la macro-fusion de faire son travail, sauf si l'instruction insérée est un NOP. Les processeurs ARM64 avaient une forme spécifique de macro-fusion, utilisée pour un cas très précis : charger une constante immédiate de 32 ou 64 bits, dans un registre 64 bits. Les CPU ARM 64 bits utilisent des instructions codées sur 32 bits, qui ne peuvent encoder que des constantes immédiates de 16 bits. Pour charger une constante de 32/64 bits, il faut s'y prendre en plusieurs fois. Une première instruction MOVZ charge les 16 premiers bits, puis 2 à 4 instructions MOVK remplissent le reste. MOVZ met à zéro les 48 bits de poids fort, MOVK permet de sélectionner le doublet à modifier dans le registre (on modifie 16 bits, dont on choisit la place, le reste du registre est laissé tel quel). ARM permet de fusionner cette suite d'instructions en une seule µop MOV avec une constante immédiate. On parle alors de '''''MOVZ + MOVK fusion'''''. Une dernière utilisation, [https://www2.eecs.berkeley.edu/Pubs/TechRpts/2016/EECS-2016-130.pdf proposées pour le jeu d'instruction RISC-V], fusionnait un calcul d'adresse et une instruction LOAD/STORE en une seule instruction d'accès mémoire. Elle a été envisagée à une époque où le jeu d'instruction RISC-V ne gérait pas les modes d'adressage base + indice. L'idée était d'avoir une unité mémoire qui gérait le mode d'adressage base + indice et de fusionner des instructions pour l'exploiter. Mais de nos jours, le jeu d'instruction RISC-V intègre le mode d'adressage base + indice, ce qui fait que la macro-fusion n'a aucun intérêt pour ce cas précis. La macro-fusion a de nombreuses limitations en pratique. En théorie, on pourrait l'utiliser au-delà des deux situations mentionnées plus haut. Par exemple, pour fusionner une addition et une multiplication en une seule instruction MAD. Mais l'intérêt est assez faible. Notons que la macro-fusion ne permet que de fusionner des paires d'instructions. Aller au-delà demanderait un cout en circuit trop important. Déjà que tester deux paires utilise pas mal de comparateurs... ==Le ''stack engine'' : une optimisation de la pile d'appel== Les processeurs modernes intègrent une optimisation liée au pointeur de pile. Pour rappel, sur les architectures modernes, le pointeur de pile est un registre utilisé pour gérer la pile d'appel, précisément pour savoir où se trouve le sommet de la pile. Il est manipulé par les instructions CALL, RET, PUSH et POP, mais aussi par les instructions d'addition/soustraction pour gérer des cadres de pile de taille variable. De plus, il peut servir d'opérande pour des instructions LOAD/STORE, afin de lire/écrire des variables locales, les arguments d'une fonction, et autres. C'est donc un registre général adressable, intégré au banc de registre, altéré par l'unité de calcul entière. L'incrémentation/décrémentation du pointeur de pile passe donc par l'unité de calcul, lors des instructions CALL, RET, PUSH et POP. Mais, l'optimisation que nous allons voir permet d'incrémenter/décrémenter le pointeur de pile sans passer par l'ALU, ou presque. L'idée est de s'inspirer des architectures avec une pile d'adresse de retour, qui intègrent le pointeur de pile dans le séquenceur et l'incrémentent avec un incrémenteur dédié. ===Le ''stack engine''=== L'optimisation que nous allons voir utilise un '''''stack engine''''' intégré à l'unité de contrôle, au séquenceur. Le processeur contient toujours un pointeur de pile dans le banc de registre, cela ne change pas. Par contre, il n'est pas incrémenté/décrémenté à chaque instruction CALL, RET, PUSH, POP. Un compteur intégré au séquenceur est incrémenté à la place, nous l’appellerons le '''compteur delta'''. La vraie valeur du pointeur de pile s'obtient en additionnant le compteur delta avec le registre dans le banc de registre. Précisons que si le compteur delta vaut zéro, la vraie valeur est dans le banc de registre et peut s'utiliser telle quelle. Lorsqu'une instruction ADD/SUB/LOAD/STORE utilise le pointeur de pile comme opérande, elle a besoin de la vraie valeur. Si elle n'est pas dans le banc de registre, le séquenceur déclenche l'addition compteur-registre pour calculer la vraie valeur. Finalement, le banc de registre contient alors la bonne valeur et l'instruction peut s'exécuter sans encombre. L'idée est que le pointeur de pile est généralement altéré par une série d'instruction PUSH/POP consécutives, puis des instructions LOAD/STORE/ADD/SUB utilisent le pointeur de pile final comme opérande. En clair, une bonne partie des incrémentations/décrémentation est accumulée dans le compteur delta, puis la vraie valeur est calculée une fois pour toutes et est utilisée comme opérande. On accumule un delta dans le compteur delta, et ce compteur delta est additionné quand nécessaire. Précisons que le compteur delta est placé juste après le décodeur d'instruction, avant même le cache de micro-opération, l'unité de renommage et l'unité d'émission. Ainsi, les incrémentations/décrémentations du pointeur de pile disparaissent dès l'unité de décodage. Elles ne prennent pas de place dans le cache de micro-opération, ni dans le ''scheduler'', ni dans la suite du pipeline. De nombreuses ressources sont économisées dans le ''front-end''. Mais utiliser un ''stack engine'' a aussi de nombreux avantages au niveau du chemin de données/''back-end''. Déjà, sur les processeurs à exécution dans le désordre, cela libère une unité de calcul qui peut être utilisée pour faire d'autres calculs. Ensuite, le compteur delta mémorise un delta assez court, de 8 bits sur le processeur Pentium M, un peu plus pour les suivants. L'incrémentation se fait donc via un incrémenteur 8 bits, pas une grosse ALU 32/64 bits. Il y a un gain en termes de consommation d'énergie, un incrémenteur 8 bits étant moins gourmand qu'une grosse ALU 32/64 bits. ===Les points de synchronisation du delta=== La technique ne fonctionne que si la vraie valeur du pointeur de pile est calculée au bon moment, avant chaque utilisation pertinente. Il y a donc des '''points de synchronisation''' qui forcent le calcul de la vraie valeur. Plus haut, nous avions dit que c'était à chaque fois qu'une instruction adresse le pointeur de pile explicitement, qui l'utilise comme opérande. Les instructions CALL, RET, PUSH et POP ne sont pas concernées par elles utilisent le pointeur de pile de manière implicite et ne font que l'incrémenter/décrémenter. Mais dans les faits, c'est plus compliqué. D'autres situations peuvent forcer une synchronisation, notamment un débordement du compteur delta. Le compteur delta est généralement un compteur de 8 bits, ce qui fait qu'il peut déborder. En cas de débordement du compteur, le séquenceur déclenche le calcul de la vraie valeur, puis réinitialise le compteur delta. La vraie valeur est donc calculée en avance dans ce cas précis. Précisons qu'un compteur delta de 8 bits permet de gérer environ 30 instructions PUSH/POP consécutives, ce qui rend les débordements de compteur delta assez peu fréquent. À noter que si le compteur delta vaut zéro, il n'y a pas besoin de calculer la vraie valeur, le séquenceur prend cette situation en compte. Un autre point de synchronisation est celui des interruptions et exceptions matérielles. Il faut que le compteur delta soit sauvegardé lors d'une interruption et restauré quand elle se termine. Idem lors d'une commutation de contexte, quand on passe d'un programme à un autre. Pour cela, le processeur peut déclencher le calcul de la vraie valeur lors d'une interruption, avant de sauvegarder les registres. Pour cela, le processeur intègre un mécanisme de sauvegarde automatique, qui mémorise la valeur de ce compteur dans le tampon de réordonnancement, pour forcer le calcul de la vraie valeur en cas de problème. La technique du ''stack engine'' est apparue sur les processeurs Pentium M d'Intel et les processeurs K10 d'AMD, et a été conservée sur tous les modèles ultérieurs. L'implémentation est cependant différente selon les processeurs, bien qu'on n'en connaisse pas les détails et que l'on doive se contenter des résultats de micro-benchmarks et des détails fournit par Intel et AMD. Selon certaines sources, dont les manuels d'optimisation d'Agner Fog, les processeurs AMD auraient moins de points de synchronisation que les processeurs Intel. De plus, leur ''stack engine'' serait placé plus loin que prévu dans le pipeline, après la file de micro-opération. ==La micro-fusion et la délamination== La '''micro-fusion''' est une optimisation qui retarde le décodage réel de certaines instructions assez loin dans le pipeline. Elle concerne surtout les instructions ''load-op'', mais pas que. D'autres instructions mémoire peuvent subir cette optimisation. L'essentiel est que, sans micro-fusion, ces instructions sont décodées en plusieurs micro-opérations. La micro-fusion retarde le décodage final de ces instructions, qui se fait assez tard dans le pipeline. Pour comprendre l'intérêt, nous allons devoir faire quelques explications. L'optimisation en question fait le décodage en deux temps. Prenons l'exemple d'une opération ''load-op'', qui est censée être décodée en deux micro-opérations : une lecture, puis une opération. Le décodage produit une '''macro-opération''' (terme d'Intel), qui est propagée dans le pipeline, pour être scindée en deux micro-opérations un peu avant les ALU et l'unité mémoire. La macro-opération est techniquement une micro-opération comme une autre, sauf que ce n'est pas la micro-opération finale. L'unité d'émission découpe cette macro-opération en deux micro-opérations, qui sont émises l'une après l'autre. Pour résumer, les instructions ''load-op'' ne sont complétement découpées en micro-opération lors de l'émission, pas lors du décodage. L'optimisation impacte ce qui se situe entre le décodeur d'instruction et l'unité d'émission : l'unité de renommage, la file de micro-opérations, le ''Loop Stream Detector'' et/ou le cache de micro-opération. L'intérêt de l'optimisation est que l'on fusionne deux micro-opérations en une seule macro-opération. Le cache de micro-opération et la file de micro-opération peuvent mémoriser plus de micro-opérations, vu que certaines sont fusionnées. Quant à l'unité de renommage, elle renomme une seule macro-opération au lieu de renommer deux micro-opérations, ce qui augmente le débit du renommage de registre. Par contre, cela complique un peu le travail de l'unité d'émission, qui doit découper les instructions ''load-op'' en deux et émettre les micro-opérations l'une après l'autre. Il faut noter qu'il n'y a pas de fusion proprement dite. Le décodeur ne décode pas deux micro-opérations séparées, avant de les fusionner. En réalité, il génère directement une macro-opération, qui correspond à deux micro-opérations, que ce soit plus tard dans le pipeline ou sur une autre architecture. D'ailleurs, la micro-fusion a lieu à l'intérieur d'une instruction, qui est censée être traduite en plusieurs micro-opérations. Et encore : pas toutes. La conséquence est que la technique n'a de sens que sur les processeurs CISC, avec des instructions assez complexes pour être décodées en plusieurs micro-opérations. En pratique, seuls les CPU x86 sont concernés. ===Les domaines de fusion=== La micro-fusion n'est pas spécifique aux processeurs superscalaires. En théorie, on peut l'implémenter sur un processeur non-superscalaire à émission dans l'ordre. Cependant, je ne connais aucun processeur à émission dans l'ordre qui implémente la micro-fusion, même si son implémentation est en théorie possible sur de tels processeurs. La micro-fusion est implémentée différemment selon que l'émission se fasse dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. J'ai dit plus haut que les micro-opérations sont générées lors de l'émission, mais le moment exact varie suivant le processeur. Pour simplifier le tout, nous parlerons de '''scission''' quand le processeur traduit une macro-opération en deux/trois micro-opérations. Le moment où a lieu la scission est théoriquement dans l'unité d'émission, mais celle-ci prend plusieurs formes selon que le processeur utilise l'émission dans l'ordre, dans le désordre, avec un ''scheduler'', avec des stations de réservation, etc. Les processeurs à exécution dans l'ordre sont le cas le plus simple. L'unité d'émission est alors en charge du découpage des macro-opérations en micro-opérations. La file de micro-opération ou le cache de micro-opérations mémorisent des macro-opérations. À l'opposé, le tampon de ré-ordonnancement mémorise lui des micro-opérations. Le reste du processeur n'est pas affecté. Sur les processeurs à exécution dans le désordre, les différences sont nombreuses. Le tampon de ré-ordonnancement peut mémoriser soit les micro-opérations finales, soit des macro-opérations. Il en est de même avec le ''scheduler'' et/ou les stations de réservation, qu'elles soient centralisés ou décentralisés. Tout dépend de comment se fait l'implémentation. Une implémentation simple effectue la scission des macro-opérations dans l'unité d'émission, comme sur les processeurs à exécution dans l'ordre. Sur de nombreux processeurs, l'unité d'émission et de renommage sont simplement fusionnées, ce qui fait que la scission a lieu juste après le renommage de registres. En conséquence, le tampon de re-ordonnancement et les ''schedulers'' ne voient que des micro-opérations. Les macro-opérations disparaissent juste avant. Cette implémentation est préférée avec un ''scheduler'' centralisé, car on voit mal comment scinder une macro-opération juste avant l'ALU. Une autre implémentation fait la scission en sortie de le ''scheduler'' ou des stations de réservation, qu'elles soient centralisés ou décentralisés. Dans ce cas, le tampon de re-ordonnancement fait de même. L'avantage est que le tampon de ré-ordonnancement stocke des macro-opérations, ce qui augmente sa taille effective. Par exemple, il utilise une seule entrée pour une instruction ''load-op'', au lieu de deux sans micro-fusion de ce type. ===La délamination=== La scission peut parfois avoir lieu plus tôt, dans des circonstances très précises. Concrètement, cela ne concerne que quelques micro-architectures d'Intel, pour des instructions très précises. La raison est les limitations du format des micro-opérations, pas une histoire d'optimisation. Le processeur aimerait pouvoir faire la micro-fusion, mais ne peut pas à cause de ces limitations. Une telle situation cause alors une '''délamination''' : les micro-opérations sont scindées précocement. Par exemple, sur la micro-architecture SandyBridge d'Intel, les opérations ''load-op'' sont fusionnées, sauf si elles utilisent l'adressage indicé. Les micro-opérations de ce processeur ne peuvent avoir que deux opérandes. Trois opérandes est de trop, et les opérations ''load-op'' en adressage indicé dépassent cette limite. Dans ce cas, les micro-opérations sont fusionnées en sortie du décodeur, mais sont scindés avant d’entrer dans la file de micro-opération. L'avantage n'est pas évident, vu que l'unité de renommage suit de près le décodeur d'instruction. Il est que le cache de micro-opérations mémorise des macro-opérations, pas deux micro-opérations. ===La micro-fusion des processeurs Intel et AMD=== La micro-fusion est apparue sur le Pentium M d'Intel, et a été conservée telle quelle sur les processeurs Intel suivants. Les processeurs AMD supportent la micro-fusion depuis au moins la micro-architecture AMD K8. Mieux que ça, la délamination n'était pas utilisée car les micro-opérations acceptaient plus de 2 opérandes. Que ce soit chez AMD ou Intel, elle concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Sur le Pentium M, il y avait quelques limitations, l'optimisation se limitant par exemple aux registres entiers et flottants, mais pas aux registres MMX, mais pas les registres SSE (nous verrons ces registres dans quelques chapitres). Mais les processeurs modernes n'ont plus cette limitation. La micro-fusion des CPU Intel concerne deux types d'instructions : les écritures mémoire et les instructions ''load-op''. Pour les écritures, la situation est plus complexe que pour les instructions ''load-op''. Sur les processeurs Intel précédents, les écritures étaient réalisées dans deux unités séparées : le calcul d'adresse dans une unité de calcul, l'écriture proprement dite dans l'unité mémoire. Elles prenaient donc deux micro-opérations. Le processeur a changé ses unités mémoire, ce qui fait qu'elle pouvait faire les deux en une seule micro-opération. Il s'agissait alors d'une optimisation du chemin de données, plus que de la micro-fusion proprement dite, mais je la mentionne ici malgré tout. La micro-fusion sur ce processeur avait un avantage en plus des avantages usuels : elle utilisait mieux les décodeurs. Les Pentium M avaient trois décodeurs pour décoder plusieurs instructions en même temps, chose qu'on détaillera dans le chapitre sur les processeurs superscalaires. Un seul décodeur pouvait décoder toutes les instructions, les deux autres ne peuvent décoder que les instructions simples, qui se décodent en une seule micro-opération. Avec la micro-fusion, les trois décodeurs peuvent gérer les écritures mémoire et les opérations ''load-op'', vu qu'elles deviennent des instructions simples. Sans cela, elles auraient été limitées au premier décodeur. <noinclude> {{NavChapitre | book=Fonctionnement d'un ordinateur | prev=Les processeurs superscalaires in-order | prevText=Les processeurs superscalairess in-order | next=Les microarchitectures pour le x86 : haute performance | nextText=Les microarchitectures pour le x86 : haute performance }} </noinclude> d58b2f26ul58wp1jsv2k9hpkyr8pjs6 Mathc complexes/0a3 0 84545 774126 773934 2026-10-04T13:28:13Z Xhungab 23827 774126 wikitext text/x-wiki [[Catégorie:Mathc complexes (livre)]] [[Mathc complexes/a277#Les fichiers principaux :| Application]] Soit le signal : s[n] = [(+1), (+2-i), (-i), (-1+2i)] Nous avons '''la transformée de Fourier discrète (TFD)''': _ N-1 \ S(k) = | s[n] exp(-2ikPI n/N) pour 0 <= k < N /_n=0 Avec N = 4 _ 3 \ S(k) = | s[n] exp(-2 i k PI n/4) /_n=0 '''Pour k = 0;''' s[n] = [(+1), (+2-i), (-i), (-1+2i)] '''[k] s[n] exp(-2i (k) PI(n)/4)''' S[0] = (+1) exp(-2i (0) PI(0)/4) + | (+1)exp(0) (+2-i) exp(-2i (0) PI(1)/4) + | (+2-i)exp(0) (-i) exp(-2i (0) PI(2)/4) + | (-i)exp(0) (-1+2i) exp(-2i (0) PI(3)/4) + | (-1+2i)exp(0) '''[[Mathc complexes/0a4|exp(0)=1]]''' Donc S(0) = (+1)(1)+ (+2-i)(1)+ (-i)(1)+ (-1+2i)(1) 1 + 2-i -i -1+2i '''S(0) = 2''' '''Pour k = 1;''' s[n] = [(+1), (+2-i), (-i), (-1+2i)] '''[k] s[n] exp(-2i (k) PI(n)/4) ''' S[1] = (+1) exp(-2i (1) PI(0)/4) + | (+1)exp(0) (+2-i) exp(-2i (1) PI(1)/4) + | (+2-i)exp(-i PI/2) (-i) exp(-2i (1) PI(2)/4) + | (-i)exp(-i PI ) (-1+2i) exp(-2i (1) PI(3)/4) + | (-1+2 i)exp(-i 3 PI/2) exp(0)=(1); exp(-i PI/2)=(-i); exp(-i PI)=(-1); exp(-i PI 3/2)=(i); Donc S(1) = (+1)(1)+ (-i)(+2-i) + (-i)(-1)+ (-1+2i)(i) S(1) = (1)+ (-2i-1)+ (i) + (-i-2) S(1) = 1 -2i-1 + i -i-2 S(1) = -2i -2 '''S(1) = -2 -2i''' '''Pour k = 2;''' s[n] = [(+1), (+2-i), (-i), (-1+2i)] '''[k] s[n] exp(-2i (k) PI(n)/4)''' S[2] = (+1) exp(-2i (2) PI(0)/4) + | (+1)exp(0) (+2-i) exp(-2i (2) PI(1)/4) + | (+2-i)exp(-i PI) (-i) exp(-2i (2) PI(2)/4) + | (-i)exp(-i 2 PI) (-1+2i) exp(-2i (2) PI(3)/4) + | (-1+2i)exp(-i 3 PI) exp(0)=(1); exp(-i PI)=(-1); exp(-i 2 PI)=(1); exp(-i 3 PI)=(-1) Donc S(2) = (+1)(1)+ (+2-i)(-1)+ (-i)(1)+ (-1+2i)(-1) S(2) = (1)+ (-2+i) + (-i) + (+1-2i) S(2) = 1 -2+i -i +1-2i '''S(2) = -2i''' '''Pour k = 3;''' s[n] = [(+1), (+2-i), (-i), (-1+2i)] '''[k] s[n] exp(-2i (k) PI(n)/4)''' S[3] = (+1) exp(-2i (3) PI(0)/4) + | (+1)exp(0) (+2-i) exp(-2i (3) PI(1)/4) + | (+2-i)exp(-i 3 PI/2) (-i) exp(-2i (3) PI(2)/4) + | (-i)exp(-i 3 PI) (-1+2i) exp(-2i (3) PI(3)/4) + | (-1+2i)exp(-i 9 PI/2) exp(0)=(1); exp(-i 3/2 PI)=(i); exp(-i 3 PI)=(-1); exp(-i 9/2 PI)=(-i) Donc S(3) = (+1)(1)+ (+2-i)(i)+ (-i)(-1) + (-1+2i)(-i) S(3) = (1)+ (+2i+1) + (i) + (+i+2) S(3) = 1 + 2i+1 + i + i+2 '''S(3) = 4 +4i''' La TFD complète : Pour : s[n] = [(+1), (+2-i), (-i), (-1+2i)] Nous avons obtenu : S(0) = +2 S(1) = -2 -2i S(2) = -2i S(3) = +4 +4i Avec les matrices : S = DFT * s +2.000 +0.000i -2.000 -2.000i +0.000 -2.000i +4.000 +4.000i {{AutoCat}} 8x6ubcvsi3q8k00egknhothvdko9cj7 774192 774126 2026-10-05T08:33:04Z Xhungab 23827 774192 wikitext text/x-wiki [[Catégorie:Mathc complexes (livre)]] [[Mathc complexes/a277#Les fichiers principaux :| Application]] Soit le signal : s[n] = [(+1), (+2-i), (-i), (-1+2i)] Nous avons '''la transformée de Fourier discrète (TFD)''': _ N-1 \ S(k) = | s[n] exp(-2ikPI n/N) pour 0 <= k < N /_n=0 Avec N = 4 _ 3 \ S(k) = | s[n] exp(-2 i k PI n/4) /_n=0 '''Pour k = 0;''' s[n] = [(+1), (+2-i), (-i), (-1+2i)] '''[k] s[n] exp(-2i (k) PI(n)/4)''' S[0] = (+1) exp(-2i (0) PI(0)/4) + | (+1)exp(0) (+2-i) exp(-2i (0) PI(1)/4) + | (+2-i)exp(0) (-i) exp(-2i (0) PI(2)/4) + | (-i)exp(0) (-1+2i) exp(-2i (0) PI(3)/4) + | (-1+2i)exp(0) '''[[Mathc complexes/0a4|exp(0)=1]]''' Donc S(0) = (+1)(1)+ (+2-i)(1)+ (-i)(1)+ (-1+2i)(1) +1 + 2-i -i -1+2i '''S(0) = 2''' '''Pour k = 1;''' s[n] = [(+1), (+2-i), (-i), (-1+2i)] '''[k] s[n] exp(-2i (k) PI(n)/4) ''' S[1] = (+1) exp(-2i (1) PI(0)/4) + | (+1)exp(0) (+2-i) exp(-2i (1) PI(1)/4) + | (+2-i)exp(-i PI/2) (-i) exp(-2i (1) PI(2)/4) + | (-i)exp(-i PI ) (-1+2i) exp(-2i (1) PI(3)/4) + | (-1+2 i)exp(-i 3 PI/2) exp(0)=(1); exp(-i PI/2)=(-i); exp(-i PI)=(-1); exp(-i PI 3/2)=(i); Donc S(1) = (+1)(1)+ (+2-i) (-i)+ (-i)(-1)+ (-1+2i)(i) S(1) = (+1) + (-2i-1) + (+i) + (-i-2) S(1) = +1 -2i-1 + i -i-2 S(1) = -2i -2 '''S(1) = -2 -2i''' '''Pour k = 2;''' s[n] = [(+1), (+2-i), (-i), (-1+2i)] '''[k] s[n] exp(-2i (k) PI(n)/4)''' S[2] = (+1) exp(-2i (2) PI(0)/4) + | (+1)exp(0) (+2-i) exp(-2i (2) PI(1)/4) + | (+2-i)exp(-i PI) (-i) exp(-2i (2) PI(2)/4) + | (-i)exp(-i 2 PI) (-1+2i) exp(-2i (2) PI(3)/4) + | (-1+2i)exp(-i 3 PI) exp(0)=(1); exp(-i PI)=(-1); exp(-i 2 PI)=(1); exp(-i 3 PI)=(-1) Donc S(2) = (+1)(1)+ (+2-i)(-1)+ (-i)(1)+ (-1+2i)(-1) S(2) = (+1) + (-2+i) + (-i) + (+1-2i) S(2) = 1 -2+i -i +1-2i '''S(2) = -2i''' '''Pour k = 3;''' s[n] = [(+1), (+2-i), (-i), (-1+2i)] '''[k] s[n] exp(-2i (k) PI(n)/4)''' S[3] = (+1) exp(-2i (3) PI(0)/4) + | (+1)exp(0) (+2-i) exp(-2i (3) PI(1)/4) + | (+2-i)exp(-i 3 PI/2) (-i) exp(-2i (3) PI(2)/4) + | (-i)exp(-i 3 PI) (-1+2i) exp(-2i (3) PI(3)/4) + | (-1+2i)exp(-i 9 PI/2) exp(0)=(1); exp(-i 3/2 PI)=(i); exp(-i 3 PI)=(-1); exp(-i 9/2 PI)=(-i) Donc S(3) = (+1)(1)+ (+2-i)(i)+ (-i)(-1) + (-1+2i)(-i) S(3) = (+1) + (+2i+1) + (i) + (+i+2) S(3) = 1 + 2i+1 + i + i+2 '''S(3) = 4 +4i''' La TFD complète : Pour : s[n] = [(+1), (+2-i), (-i), (-1+2i)] Nous avons obtenu : S(0) = +2 S(1) = -2 -2i S(2) = -2i S(3) = +4 +4i Avec les matrices : S = DFT * s +2.000 +0.000i -2.000 -2.000i +0.000 -2.000i +4.000 +4.000i {{AutoCat}} iw062d3l7461gr3fs7xie3roscr4wyc Mathc complexes/0a9 0 84551 774121 774074 2026-10-04T13:25:16Z Xhungab 23827 774121 wikitext text/x-wiki [[Catégorie:Mathc complexes (livre)]] [[Mathc complexes/a277#Les fichiers principaux :| Application]] Soit S(k), la TFD du signal s[n] : S(k) = ((+8), (+3-i), (-6), (+3+i)) Calculons s[n] : Nous avons '''la transformée de Fourier discrète Inverse (TFDI) :''' _ N-1 \ s[n] = 1/N | S(k) exp(i 2PI/N nk) pour 0 <= n < N /_k=0 Avec N = 4 _ N-1 \ s[n] = 1/4 | S(k) exp(i 2PI/4 nk) pour 0 <= n < N /_k=0 '''Pour n = 0;''' S(k) = ((+8), (+3-i), (-6), (+3+i)) '''[n] S(k) exp(i 2PI/4 n k)''' s[0] = (+8) exp(i 2PI/4 (0) (0)) + | (+8) exp(0) + (+3- i) exp(i 2PI/4 (0) (1)) + | (+3- i) exp(0) + (-6) exp(i 2PI/4 (0) (2)) + | (-6) exp(0) + (+3+i) exp(i 2PI/4 (0) (3)) + | (+3+i) exp(0) [[Mathc complexes/0ab| exp(0)=1]] Donc s[0] = [(+8)exp(0) + (+3-i)exp(0) + (-6)exp(0) + (+3+i)exp(0)]/N s[0] = [(+8)(1) + (+3-i)(1) + (-6)(1) + (+3+i)(1) ]/N '''s[0]''' = [8]/N = 8/4 = '''2''' '''Pour n = 1;''' S(k) = ((+8), (+3-i), (-6), (+3+i)) '''[n] S(k) exp(i 2PI/4 n k)''' s[1] = (+8) exp(i 2PI/4 (1) (0)) + | (+8) exp(0) + (+3-i) exp(i 2PI/4 (1) (1)) + | (+3-i) exp(i PI/2) + (-6) exp(i 2PI/4 (1) (2)) + | (-6) exp(i PI) + (+3+i) exp(i 2PI/4 (1) (3)) + | (+3+i) exp(i 3PI/2) exp(0) =(1); exp(i PI/2) = (i); exp(i PI) = (-1); exp(i PI 3/2) = (-i); Donc s[1] = [(+8)exp(0) +(+3-i)exp(i PI/2) +(-6)exp(i PI) +(+3+i)exp(i 3PI/2)]/N s[1] = [(+8)(1) +(+3-i)(i) +(-6)(-1) +(+3+i)(-i) ]/N '''s[1]''' = [16]/N = 16/4 = '''4''' '''Pour n = 2;''' S(k) = ((+8) , (+3-i) , (-6), (+3+i)) '''[n] S(k) exp(i 2PI/4 n k)''' s[2] = (+8) exp(i 2PI/4 (2) (0)) + | (+8) exp(0) + (+3- i) exp(i 2PI/4 (2) (1)) + | (+3- i) exp(i PI) + (-6) exp(i 2PI/4 (2) (2)) + | (-6) exp(i 2PI) + (+3+i) exp(i 2PI/4 (2) (3)) + | (+3+i) exp(i 3PI) exp(0) =(1); exp(i PI)=(-1) ; exp(i 2 PI) = (1); exp(i 3 PI) = (-1) Donc s[2 ]= [(+8)exp(0) +(+3-i)exp(i PI) +(-6)exp(i 2PI) +(+3+i)exp(i 3PI)]/N s[2] = [(+8)(1) +(+3-i)(-1) +(-6)(1) +(+3+i)(-1) ]/N '''s[2]''' = [-4]/N =-4/4 = '''-1''' '''Pour n = 3;''' S(k) = ((+8), (+3-i), (-6), (+3+i)) '''[n] S(k) exp(i 2PI/4 n k) ''' s[3] = (+8) exp(i 2PI/4 (3) (0)) + | (+8) exp(0) + (+3- i) exp(i 2PI/4 (3) (1)) + | (+3- i) exp(i 3PI/2) + (-6) exp(i 2PI/4 (3) (2)) + | (-6) exp(i 3PI) + (+3+i) exp(i 2PI/4 (3) (3)) + | (+3+i) exp(i 9PI/2) exp(0 =(1); exp(i 3PI/2) = (-i); exp(i 3PI)= (-1); exp(i 9PI/2) = (i) Donc s[3] = [(+8)exp(0) +(+3-i)exp(i 3PI/2) +(-6)exp(i 3PI) +(+3+i)exp(i 9PI/2)]/N s[3] = [(+8)(1) +(+3-i) (-i) +(-6)(-1) +(+3+i)(i) ]/N '''s[3]''' = [12]/N= 12/4 = '''3''' La TFDI complète : Pour : S(k) = ((+8), (+3-i), (-6), (+3+i)) Nous avons obtenu : s[0] = +2 s[1] = +4 s[2] = -1 s[3] = +3 Avec les matrices : s = 1/4 cDFT * S : +2.000 -0.000i +4.000 -0.000i -1.000 +0.000i +3.000 +0.000i {{AutoCat}} 4w8xtcu30qtd6jcbslelnnbhj706awj 774123 774121 2026-10-04T13:25:49Z Xhungab 23827 774123 wikitext text/x-wiki [[Catégorie:Mathc complexes (livre)]] [[Mathc complexes/a277#Les fichiers principaux :| Application]] Soit S(k), la TFD du signal s[n] : S(k) = ((+8), (+3-i), (-6), (+3+i)) Calculons s[n] : Nous avons '''la transformée de Fourier discrète Inverse (TFDI) :''' _ N-1 \ s[n] = 1/N | S(k) exp(i 2PI/N nk) pour 0 <= n < N /_k=0 Avec N = 4 _ N-1 \ s[n] = 1/4 | S(k) exp(i 2PI/4 nk) pour 0 <= n < N /_k=0 '''Pour n = 0;''' S(k) = ((+8), (+3-i), (-6), (+3+i)) '''[n] S(k) exp(i 2PI/4 n k)''' s[0] = (+8) exp(i 2PI/4 (0) (0)) + | (+8) exp(0) + (+3- i) exp(i 2PI/4 (0) (1)) + | (+3- i) exp(0) + (-6) exp(i 2PI/4 (0) (2)) + | (-6) exp(0) + (+3+i) exp(i 2PI/4 (0) (3)) + | (+3+i) exp(0) '''[[Mathc complexes/0ab|exp(0)=1]]''' Donc s[0] = [(+8)exp(0) + (+3-i)exp(0) + (-6)exp(0) + (+3+i)exp(0)]/N s[0] = [(+8)(1) + (+3-i)(1) + (-6)(1) + (+3+i)(1) ]/N '''s[0]''' = [8]/N = 8/4 = '''2''' '''Pour n = 1;''' S(k) = ((+8), (+3-i), (-6), (+3+i)) '''[n] S(k) exp(i 2PI/4 n k)''' s[1] = (+8) exp(i 2PI/4 (1) (0)) + | (+8) exp(0) + (+3-i) exp(i 2PI/4 (1) (1)) + | (+3-i) exp(i PI/2) + (-6) exp(i 2PI/4 (1) (2)) + | (-6) exp(i PI) + (+3+i) exp(i 2PI/4 (1) (3)) + | (+3+i) exp(i 3PI/2) exp(0) =(1); exp(i PI/2) = (i); exp(i PI) = (-1); exp(i PI 3/2) = (-i); Donc s[1] = [(+8)exp(0) +(+3-i)exp(i PI/2) +(-6)exp(i PI) +(+3+i)exp(i 3PI/2)]/N s[1] = [(+8)(1) +(+3-i)(i) +(-6)(-1) +(+3+i)(-i) ]/N '''s[1]''' = [16]/N = 16/4 = '''4''' '''Pour n = 2;''' S(k) = ((+8) , (+3-i) , (-6), (+3+i)) '''[n] S(k) exp(i 2PI/4 n k)''' s[2] = (+8) exp(i 2PI/4 (2) (0)) + | (+8) exp(0) + (+3- i) exp(i 2PI/4 (2) (1)) + | (+3- i) exp(i PI) + (-6) exp(i 2PI/4 (2) (2)) + | (-6) exp(i 2PI) + (+3+i) exp(i 2PI/4 (2) (3)) + | (+3+i) exp(i 3PI) exp(0) =(1); exp(i PI)=(-1) ; exp(i 2 PI) = (1); exp(i 3 PI) = (-1) Donc s[2 ]= [(+8)exp(0) +(+3-i)exp(i PI) +(-6)exp(i 2PI) +(+3+i)exp(i 3PI)]/N s[2] = [(+8)(1) +(+3-i)(-1) +(-6)(1) +(+3+i)(-1) ]/N '''s[2]''' = [-4]/N =-4/4 = '''-1''' '''Pour n = 3;''' S(k) = ((+8), (+3-i), (-6), (+3+i)) '''[n] S(k) exp(i 2PI/4 n k) ''' s[3] = (+8) exp(i 2PI/4 (3) (0)) + | (+8) exp(0) + (+3- i) exp(i 2PI/4 (3) (1)) + | (+3- i) exp(i 3PI/2) + (-6) exp(i 2PI/4 (3) (2)) + | (-6) exp(i 3PI) + (+3+i) exp(i 2PI/4 (3) (3)) + | (+3+i) exp(i 9PI/2) exp(0 =(1); exp(i 3PI/2) = (-i); exp(i 3PI)= (-1); exp(i 9PI/2) = (i) Donc s[3] = [(+8)exp(0) +(+3-i)exp(i 3PI/2) +(-6)exp(i 3PI) +(+3+i)exp(i 9PI/2)]/N s[3] = [(+8)(1) +(+3-i) (-i) +(-6)(-1) +(+3+i)(i) ]/N '''s[3]''' = [12]/N= 12/4 = '''3''' La TFDI complète : Pour : S(k) = ((+8), (+3-i), (-6), (+3+i)) Nous avons obtenu : s[0] = +2 s[1] = +4 s[2] = -1 s[3] = +3 Avec les matrices : s = 1/4 cDFT * S : +2.000 -0.000i +4.000 -0.000i -1.000 +0.000i +3.000 +0.000i {{AutoCat}} bkkm2y7tte3loirek1cqdayzbol6fn2 Mathc complexes/0aa 0 84552 774124 774099 2026-10-04T13:26:29Z Xhungab 23827 774124 wikitext text/x-wiki [[Catégorie:Mathc complexes (livre)]] [[Mathc complexes/a277#Les fichiers principaux :| Application]] Soit S(k), la TFD du signal s[n] : S(k) = ((+2), (-2-2i), (-2i), (+4+4i)) Calculons s[n] : Nous avons '''la transformée de Fourier discrète Inverse (TFDI) :''' _ N-1 \ s[n] = 1/N | S(k) exp(i 2PI/N nk) pour 0 <= n < N /_k=0 Avec N = 4 _ N-1 \ s[n] = 1/4 | S(k) exp(i 2PI/4 nk) pour 0 <= n < N /_k=0 '''Pour n = 0;''' S(k) = ((+2), (-2-2i), (-2i), (+4+4i)) '''[n] S(k) exp(i 2PI/4 n k)''' s[0] = (+2) exp(i 2PI/4 (0) (0)) + | (+2) exp(0) + (-2-2i) exp(i 2PI/4 (0) (1)) + | (-2-2i) exp(0) + (-2i) exp(i 2PI/4 (0) (2)) + | (-2i) exp(0) + (+4+4i) exp(i 2PI/4 (0) (3)) | (+4+4i) exp(0) '''[[Mathc complexes/0ab|exp(0)=1]]''' Donc s[0] = [(+2)exp(0) +(-2-2i)exp(0) +(-2i)exp(0) +(+4+4i)exp(0)]/N s[0] = [(+2)(1) +(-2-2i)(1) +(-2i)(1) +(+4+4i)(1) ]/N '''s[0]''' = [4]/N = 4/4 = '''+1''' '''Pour n = 1;''' S(k) = ((+2), (-2-2i), (-2i), (+4+4i)) '''[n] S(k) exp(i 2PI/4 n k)''' s[1] = (+2) exp(i 2PI/4 (1) (0)) + | (+2) exp(0)) + (-2-2i) exp(i 2PI/4 (1) (1)) + | (-2-2i) exp(i PI/2) + (-2i) exp(i 2PI/4 (1) (2)) + | (-2i) exp(i PI) + (+4+4i) exp(i 2PI/4 (1) (3)) | (+4+4i) exp(i 3PI/2) exp(0) =(1); exp(i PI/2) = (i); exp(i PI) = (-1); exp(i 3PI/2) = (-i); Donc s[1] = [(+2)exp(0)) +(-2-2i)exp(i PI/2) +(-2i)exp(i PI ) +(+4+4i)exp(i 3PI/2)]/N s[1] = [(+2)(1)) +(-2-2i)(i) +(-2i)(-1) +(+4+4i)(-i) ]/N '''s[1]''' = [4 (+2-i)]/N = 4 (+2-i)/4 = ''' (+2-i)''' '''Pour n = 2;''' S(k) = ((+2), (-2-2i), (-2i), (+4+4i)) '''[n] S(k) exp(i 2PI/4 n k)''' s[2] = (+2) exp(i 2PI/4 (2) (0)) + | (+2) exp(0) + (-2-2i) exp(i 2PI/4 (2) (1)) + | (-2-2i) exp(i PI) + (-2i) exp(i 2PI/4 (2) (2)) + | (-2i) exp(i 2PI) + (+4+4i) exp(i 2PI/4 (2) (3)) | (+4+4i) exp(i 3Pi) exp(0) =(1); exp(i PI)=(-1); exp(i 2PI) = (1); exp(i 3PI) = (-1) Donc s[2 ]= [(+2)exp(0) +(-2-2i)exp(i PI)) +(-2i) exp(i 2PI) + (+4+4i)exp(i 3Pi)]/N s[2] = [(+2)(1) +(-2-2i)(-1) +(-2i)(1) + (+4+4i)(-1) ]/N '''s[2]''' = [-4i]/N =-4i/4 = '''-i''' '''Pour n = 3;''' S(k) = ((+2), (-2-2i), (-2i), (+4+4i)) '''[n] S(k) exp(i 2PI/4 n k) ''' s[3] = ( +2) exp(i 2PI/4 (3) (0)) + | (+2) exp(0) + (-2-2i) exp(i 2PI/4 (3) (1)) + | (-2-2i) exp(i 3PI/2) + (-2i) exp(i 2PI/4 (3) (2)) + | (-2i) exp(i 3PI) + (+4+4i) exp(i 2PI/4 (3) (3)) | (+4+4i) exp(i 9PI/2) exp(0 =(1); exp(i 3PI/2) = (-i); exp(i 3PI)= (-1); exp(i 9PI/2) = (i) Donc s[3] = [(+2)exp(0) +(-2-2i)exp(i 3PI/2) +(-2i)exp(i 3PI) +(+4+4i)exp(i 9PI/2)]/N s[3] = [(+2)(1) +(-2-2i)(-i) +(-2i)(-1) +(+4+4i)(i) ]/N '''s[3]''' = [4(-1+2i)]/N= 4(-1+2i)/4 = '''(-1+2i)''' La TFDI complète : Pour : S(k) = ((+2), (-2-2i), (-2i), (+4+4i)) Nous avons obtenu : s[0] = +1 s[1] = +2- i s[2] = - i s[3] = -1+2i Avec les matrices : s = 1/4 cDFT4 * S : +1.000+0.000i +2.000-1.000i -0.000-1.000i -1.000+2.000i {{AutoCat}} djvc86rs1wpnox8l5ajjwe9cd5lkxu6 Mathc complexes/0ac 0 84554 774131 2026-10-04T13:34:05Z Xhungab 23827 news 774131 wikitext text/x-wiki [[Catégorie:Mathc complexes (livre)]] [[Mathc complexes/a277#Les fichiers principaux :| Application]] '''S'entraîner sur les nombres complexes :''' '''(+3-i)(+i)''' = +3i + 1 '''(+3-i)(-i) ''' = -3i - 1 '''(+3+i)(+i)''' = +3i - 1 '''(+3+i)(-i)''' = -3i + 1 {{AutoCat}} sxzex8mk7rsn4pvdhul6v2sx7qkotjd