Aller au contenu

Fonctionnement d'un ordinateur/Les jeux d'instructions

Un livre de Wikilivres.

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

[modifier | modifier le wikicode]

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

[modifier | modifier le wikicode]

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. A 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.

Différences CISC/RISC
Propriété CISC RISC
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.
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
Registres
  • Peu de registres : rarement plus de 16 registres entiers.
  • Beaucoup de registres, souvent plus de 32.

Les performances relatives des CISC/RISC

[modifier | modifier le wikicode]

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.
, avec N le nombre moyen d'instruction du programme et 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 . 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

[modifier | modifier le wikicode]

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

[modifier | modifier le wikicode]

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 de ces architectures CISC et RISC, qui sont en quelque sorte la base de tous les jeux d'instructions, d'autres classes de jeux d'instructions sont apparus, assez différents des jeux d’instructions RISC et CISC. On peut par exemple citer le Very Long Instruction Word, qui sera abordé dans les chapitres à la fin du tutoriel. La plupart de ces jeux d'instructions sont implantés dans des processeurs spécialisés, qu'on fabrique pour une utilisation particulière. Ce peut être pour un langage de programmation particulier, pour des applications destinées à un marché de niche comme les supercalculateurs, etc.

Les jeux d'instructions spécialisés

[modifier | modifier le wikicode]

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. Malheureusement, nous ne pouvons pas en parler pour le moment. Dans ce qui va suivre, nous allons surtout nous concentrer sur les jeu d'instructions adaptés à certaines catégories de programmes ou de langages de programmation.

Les architectures dédiées à un langage de programmation

[modifier | modifier le wikicode]

Certains processeurs sont carrément conçus pour un langage de programmation en particulier. On appelle ces processeurs, conçus pour des besoins particuliers, des processeurs dédiés. Par exemple, l'ALGOL-60, le COBOL et le FORTRAN ont eu leurs architectures dédiées. Les fameux Burrough E-mode B5000/B6000/B7000 étaient spécialement conçus pour exécuter de l'ALGOL-60. Leurs cousins B2000/B3000/B4000 étaient eux conçus pour le COBOL. Le FORTH, un des premiers langages à pile de haut niveau, possède de nombreuses implémentations hardware et est un des rares langages de haut niveau à avoir été directement câblé en assembleur sur certains processeurs. Par exemple, on peut citer le processeur FC16, capable d’exécuter nativement du FORTH.

Des langages fonctionnels ont aussi eu droit à leurs processeurs dédiés. Le prolog en est un bel exemple, avec les superordinateurs de 5ème génération qui lui étaient dédié. On peut aussi citer les machines LISP, dédiés au langage LISP, qui datent des années 1970. Elles étaient capables d’exécuter certaines fonctions de base du langage directement dans leur circuits : elles possédaient notamment un garbage collector câblé dans ses circuits ainsi que des instructions machines supportant un typage déterminé à l’exécution.

Les processeurs dédiés ont eu leur heure de gloire au début de l'informatique, à une époque où les langages de haut niveau venaient d'être inventés. À cette époque, les compilateurs n'étaient pas performants et ne savaient pas bien optimiser le code machine. Il était alors rationnel, pour l'époque, de rapprocher le code machine cible et le langage de programmation de haut niveau. De nombreuses architectures dédiés ont ainsi étés inventées, avant que les concepteurs se rendent compte des défauts de cette approche. À l'heure actuelle, les algorithmes des compilateurs se sont améliorés et savent nettement mieux utiliser le matériel. Ils produisent du code machine efficace, ce qui rend les architecture dédiées bien moins intéressantes. Si on ajoute les défauts de ces architectures dédiées, par étonnant que les architectures dédiées aient presque disparues.

Les défauts en question ne sont pas nombreux, mais assez simples à comprendre. Premièrement, elles sont très rapides pour un langage de programmation en particulier, mais sont assez mauvaises pour les autres, d'où un problème de "compatibilité". Ajoutons à cela que les langages de programmation peuvent évoluer, devenir de moins en moins populaires/utilisés, ce qui rend la création d'architectures généralistes plus pertinente. Enfin, les architectures dédiées sont évidemment des processeurs CISC, pour implémenter les nombreuses fonctionnalités des langages évolués. Et les défauts des CISC sont assez rédhibitoires à l'heure actuelle.

Les implémentations matérielles de machines virtuelles

[modifier | modifier le wikicode]

Comme vous le savez sûrement, les langages de programmation de haut niveau sont traduits en langage machine. La traduction d'un programme en un fichier exécutable est donc un processus en deux étapes. Premièrement, la compilation traduit le code source en langage assembleur, une représentation textuelle du langage machine. Puis l'assembleur est assemblé en langage machine. Les deux étapes sont réalisées respectivement par un compilateur et un assembleur.

Représentation intermédiaire d'un compilateur.

Le compilateur ne fait pas forcément la traduction en assembleur directement, la plupart des compilateurs modernes passent par un langage intermédiaire, avant d'être transformés en langage machine. Pour cela, le compilateur est composé de deux parties : une partie commune qui traduit le C en langage intermédiaire, et plusieurs back-end qui traduisent le langage intermédiaire en code machine cible.

Faire ainsi a de nombreux avantages pour les concepteurs de compilateurs. Notamment, cela permet d'avoir un compilateur qui traduit le langage de haut niveau pour plusieurs jeux d’instructions différents, par exemple un compilateur qui traduit du C soit en code machine x86, soit en code machine pour un CPU ARM, soit pour un CPU POWERPC, etc.

Le langage intermédiaire peut être vu comme l'assembleur d'une machine abstraite, que l'on appelle une machine virtuelle, qui n'existe pas forcément dans la réalité. Le langage intermédiaire est conçu de manière à ce que la traduction en code machine soit la plus simple possible. Et surtout, il est conçu pour pouvoir être transformé en plusieurs langages machines différents sans trop de problèmes. Par exemple, il dispose d'un nombre illimité de registres. Lors de la transformation en code machine, un algorithme d'allocation de registres se débrouille pour traduire le code intermédiaire en code qui utilise des registres ou des adresses, en insérant des instructions d'accès mémoire. Et cela permet de gérer des architectures très différentes qui n'ont pas les mêmes nombres de registres.

Control table

Mais si la majorité des langages de haut niveau sont compilés, il en existe qui sont interprétés, c'est à dire que le code source n'est pas traduit directement, mais transformé en code machine à la volée. Là encore, on trouve un langage intermédiaire appelé le bytecode . Le langage de haut niveau est traduit en bytecode, via un processus de pré-compilation, et c'est ce bytecode qui est "exécuté". Plus précisément, le bytecode est passé à un logiciel appelé l'interpréteur, qui lit une instruction à la fois dans le bytecode et exécute une instruction équivalente sur le processeur. Si l'instruction est un peu complexe, il exécute une fonction/procédure qui fait la même chose. Pour résumer, le bytecode est ensuite traduit à la volée et exécuté instruction par instruction par un logiciel appelé l'interpréteur.

Fonctionnement d'un interpréteur.

L'avantage est celui de la portabilité, à savoir qu'un même code peut tourner sur plusieurs machines différentes. Le code machine est spécifique à un jeu d’instruction, la compatibilité est donc limitée. C'est très utilisé par certains langages de programmation comme le Python ou le Java, afin d'obtenir une bonne compatibilité : on compile le Python/Java en bytecode, qui lui-même est interprété à l’exécution. Tout ordinateur sur lequel on a installé une machine virtuelle Java/Python peut alors exécuter ce bytecode.

Le bytecode est un code machine, ce qui signifie qu'il peut en théorie s'exécuter sur un processeur qui implémente le jeu d’instruction associé. Le processeur en question n'existe pas forcément, mais il est possible de décrire son jeu d'instruction en détail. Lesbytecode assez anciens sont conçus pour un type d'architecture spécifique, appelé une machine à pile, qu'on verra dans quelques chapitres. Elles ont la particularité de ne pas avoir de registres et ont un avantage quant à la taille du bytecode. Il existe un algorithme simple et rapide pour traduire un code écrit pour une machine à pile en un code écrit pour un processeur avec des registres, qui se débrouille pas trop mal pour allouer efficacement les registres. C'est un avantage assez important pour les langages interprétés.

Si le jeu d'instruction d'un bytecode est souvent une description censée être fictive, elle n'en reste pas moins un jeu d'instruction et des caractéristiques précises, qu'on peut l'implémenter en matériel ! Un premier exemple est celui des processeurs Pascal MicroEngine, qui exécutaient directement le bytecode du langage Pascal, le fameux UCSD P-code. Un second exemple est la machine SECD, qui sert de langage intermédiaires pour certains compilateurs de langages fonctionnels. Elle a été implémentée en matériel par plusieurs équipes, notamment par les chercheurs de l'université de Calgary en 1989. Dans le même genre, quelques processeurs simples étaient capables d’exécuter directement le bytecode du langage FORTH.

Mais le cas le plus impressionnant est celui de la machine virtuelle Java, qui est un design de processeur comme un autre. En temps normal, le bytecode Java est compilé ou interprété, ce qui permet au bytecode Java d'être portable sur des architectures différentes. Mais certains processeurs ARM, qu'on trouve dans des système embarqués, sont une implémentation matérielle de la machine virtuelle Java. Leur langage machine est le bytecode Java lui-même, ce qui supprime l'étape d'interprétation. L'intérêt de ce genre de stratagèmes reste cependant mince. Cela permet d’exécuter plus vite les programmes compilés en bytecode, comme des programmes Java pour la JVM Java, mais cela n'a guère plus d'intérêt.