Fonctionnement d'un ordinateur/Les ISA optimisés pour la compilation/interprétation
De nos jours, la majorité des programmeurs programment dans des langages de haut niveau. Il est très rare pour eux d'avoir à utiliser de l'assembleur, et encore moins un langage machine. Les programmes écrits dans un langage de haut niveau sont traduits en langage machine par un logiciel appelé le compilateur.
Il se trouve que quelques rares processeurs sont conçus pour faciliter le travail du compilateur. Et ce sont ces processeurs que nous allons voir dans ce qui suit. Ils représentent une catégorie de jeux d'instruction à part, qui n'a pas vraiment de nom.
La compilation et l’interprétation
[modifier | modifier le wikicode]Un compilateur traduit donc du code source, écrit dans un langage de haut niveau, vers du langage machine. La traduction est rarement directe. En général, le compilateur traduit le code source en assembleur, qui est lui-même traduit en langage machine. L'assembleur est une représentation textuelle du langage machine alors que le code machine est du binaire exécutable par le processeur. La traduction se fait donc en deux étapes, la compilation proprement dite et l'assemblage, réalisées respectivement par un compilateur et un assembleur. Il faut parfois rajouter une troisième phase d'édition des liens, que nous passons volontairement sous silence.
L'interprétation et la compilation à la volée
[modifier | modifier le wikicode]Une alternative à la compilation est l'interprétation, qui transforme le code source en code machine à la volée. Avec l'interprétation, le code source est passé à un logiciel appelé l'interpréteur, qui exécute le code source ligne de code par ligne de code. Une ligne de code est traduite en code machine, qui est exécutée, puis l'interpréteur passe à la ligne suivante.

Un défaut de l'interprétation est son cout en performance, qui est assez important. Aussi, l'interprétation stricte a évolué vers un hybride entre interprétation et compilation. L'idée est qu'une partie peu importante du code source est interprétée, alors que le code important est compilé juste avant d'être exécuté. Pour le dire autrement, le code source est partiellement compilé à la volée, juste avant son exécution. Aussi, on parle de compilation à la volée. Le terme en anglais est Just In Time compilation, abrévié en JIT. En soi, le JIT est associé à de l'interprétation, les JIT sont en réalité des hybrides interpréteurs-compilateurs.
Un point important est que compiler du code à la volée est assez lourd, cela a un cout en performance. Par contre, le code compilé s'exécute plus vite que du code interprété. Si on exécute le code compilé une seule fois, le cout de la compilation l'emporte sur le gain à l'exécution. Mais le code compilé est réutilisé autant de fois que nécessaire. Le gain à l'exécution est donc multiplié par le nombre d'exécutions, alors que le cout de la compilation est répartit sur plusieurs exécutions, il est amorti.
Les portions du code sont choisies de manière à avoir un gain en performance maximal. L'idée générale est que le code exécuté très souvent est compilé, alors que du code exécuté pas souvent est interprété. Typiquement, les boucles les plus souvent exécutées sont compilées, le code exécuté une seule fois est interprété.
Les langages intermédiaires
[modifier | modifier le wikicode]Les compilateurs modernes passent par un langage intermédiaire pour faire la transformation en code machine. Le compilateur traduit le langage de haut niveau en langage intermédiaire, puis traduit le langage intermédiaire en code machine cible. Le langage intermédiaire est parfois appelé le bytecode, et ce terme recouvre aussi le code écrit avec du bytecode. L'interprétation aussi peut passer par l'intermédiaire d'un un bytecode, ce n'est pas limité aux compilateurs proprement dit.
Faire ainsi a de nombreux avantages, le principal étant d'avoir un compilateur capable de traduire un langage de haut niveau vers plusieurs jeux d'instructions différents. Cela permet d'avoir, 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.

Pour l'interprétation, l'usage d'un bytecode a d'autres avantages. Pour l'exploiter, il faut distribuer non pas le code source, mais le bytecode. En clair, l'interpréteur ne prend pas en entrée le code source, mais du bytecode déjà compilé. La traduction est alors beaucoup plus simple, car le code source a déjà été partiellement compilé, par la traduction en bytecode. Interpréter du code source est en effet assez compliqué : il faut effectuer des étapes d'analyse lexicale, sémantique, et bien d'autres. Avec du bytecode, ces étapes ont été réalisées lors de la compilation du bytecode, l'interpréteur a alors peut de choses à faire.
L'interprétation du bytecode se fait instruction par instruction, au niveau du bytecode. Une instruction du bytecode est traduite en une instruction machine équivalente, qui est exécutée. 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.

Le langage intermédiaire peut être vu comme l'assembleur d'un processeur, n'existe pas forcément dans la réalité, mais dont le jeu d'instruction est décrit en détail. Le processeur en question est appelé une machine abstraite, ou encore une machine virtuelle. Nous utiliserons le terme de machine abstraite dans ce qui suit.
La machine abstraite n'est pas la même suivant que l'on cible la compilation ou l'interprétation/JIT. Pour l'interprétation/JIT, la machine abstraite est souvent une machine à pile, car cela simplifie grandement la traduction en code machine final. L'interprétation demande que la traduction du bytecode en code machine soit la plus simple possible. Et cela demande de prendre en compte pas mal de détails.
Par exemple, les différents jeux d'instruction existants n'ont pas le même nombre de registres, ce qui pose problème lors de la traduction du bytecode. 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 un nombre limité de registres. Pour améliorer cette allocation de registres, il y a deux solutions niveau bytecode : soit utiliser un nombre illimité de registres, soit utiliser une machine à pile. Pour les machines à pile, 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.
Les processeurs qui exécutent du bytecode
[modifier | modifier le wikicode]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é. 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 ! Néanmoins, tous les bytecode ne sont pas égaux de ce point de vue. Certains sont faciles à implémenter en matériel, d'autres non. Les bytecodes implémentés en matériel sont ceux dont les machines abstraites sont des machines à pile.
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édiaire 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.
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 dont le bytecode a été utilisé comme langage machine sur certains processeurs. Par exemple, on peut citer le processeur FC16, capable d’exécuter nativement du bytecode FORTH.
Les processeurs Java
[modifier | modifier le wikicode]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é, mais certains processeurs exécutaient du bytecode Java directement, sans interprétation ni compilation. Ils sont appelés des processeurs Java. On peut citer les processeurs ARM disposant de l'extension Jazelle, du JEMCore et du aJ-100 de aJile Systems, le picoJava II de Sun, et quelques autres. Il y a aussi eu quelques projets de recherche ou processeurs open source, comme Komodo, jamuth, le Java Optimized Processor et quelques autres projets du même genre.
L'implémentation de la machine virtuelle Java n'était cependant pas complète. Quelques instructions complexes n'étaient pas gérées par le processeur et devaient être émulées en logiciel. Mais la grosse majorité des instructions l'était. Les instructions non-supportées étaient émulées avec la méthode trap and emulate, à savoir qu'un opcode inconnu déclenchait une exception matérielle opcode invalide, dont la routine pouvait émuler les instructions qui doivent l'être. Le processeur aJ-100 de aJile est capable d'exécuter la quasi-totalité des instructions de la JVM Java, seules deux instructions faisant exception.
Les accélérateurs Java sont une solution intermédiaire, qui permet de traduire à la volée du bytecode Java en instructions RISC, exécutées sur un autre processeur. Il s'agit formellement de traduction binaire, mais je préfère en parler ici. L'idée est de combiner un processeur RISC avec un coprocesseur Java. Le coprocesseur lit le bytecode Java et le traduit en instructions RISC, ces dernières étant exécutées sur le processeur RISC. Le JA108 de Nozomi était un coprocesseur de ce genre, au même titre que le coprocesseur JSTAR de JEDI. L'extension Jazelle de certains CPU ARM fonctionnait vraisemblablement sur un principe similaire, sauf que le coprocesseur était intégré dans le processeur ARM, entre le cache d'instruction et le décodeur d'instructions ARM.
L'intérêt des processeurs Java n'est pas qu'une question de performance. Certes, ils permettent d’exécuter plus vite les programmes compilés en bytecode, comme des programmes Java pour la JVM Java. Mais l'intérêt est aussi de faire des économies de mémoire RAM et ROM. Je rappelle que la machine virtuelle Java est une machine à pile, ce qui fait que sa densité de code est excellente. Les programmes en bytecode Java sont donc très petits, et prennent peu de place en mémoire ROM. De plus, utiliser un processeur Java permet de se passer d'interpréteur ou de JIT Java, ce qui économise encore plus de mémoire.
Transformer un processeur RISC en machine à pile
[modifier | modifier le wikicode]La plupart des processeurs Java utilisent en interne une architecture à registre généraux, qui émule une machine à registre en microcode. Quelques registres sont réservés pour l'état de la machine virtuelle, avec au minimum un registre réservé pour le pointeur de pile, un autre pour un pointeur vers un pool de constantes, et quelques autres. D'autres registres servent à mémoriser le haut de la pile, à savoir le sommet de la pile et quelques opérandes situées juste en-dessous. Par exemple, le processeur picoJava II gardait les 64 opérandes au sommet de la pile dans 64 registres généraux, séparés des registres pour les pointeurs et l'état de la JVM.
Une optimisation possible, envisagée sur les processeurs picoJava, était une sorte de macro-fusion sous stéroïde. L'idée était de fusionner certaines séries d'instructions Java en une seule instruction machine. Typiquement, pour faire une opération arithmétique, une machine à pile empile deux opérandes et exécute une opération, ce qui prend trois instructions machine. La spécification picoJava 1 a proposé d'envoyer directement la seconde opérande en entrée de l'ALU, ce qui fusionnait l'empilement de la seconde opérande et l'opération arithmétique. Le processeur picoJava II va plus loin et fusionne les trois instructions en une seule instruction RISC, agissant sur des registres. Mais la majorité des processeurs Java ne faisaient pas cela, car cela complexifie grandement le décodeur.
Les processeurs bigfoot de DCT utilisaient un système différent pour émuler une pile avec des registres. Leur banc de registre de 64 bits était coupé en deux : les 16 premiers registres étaient utilisés pour la pile, les 48 suivants étaient utilisés pour autre chose. L'idée est que le registre 0 mémorise un emplacement vide, le registre 1 contient le sommet de la pile, le registre 2 l'opérande sous le sommet de la pile, et ainsi de suite. Les 15 premières opérandes de la pile sont donc mémorisées dans les registres.
Les instructions écrivent leur résultat dans le registre adéquat, puis tous les registres sont décalés pour que le résultat passe dans le registre R1. Par exemple, les instructions PUSH chargent une opérande dans le registre 0, puis tous les registres sont décalés de un rang, pour que l'opérande chargée soit dans le registre 1. Le registre 0 devient alors le registre numéro 1, le registre 1 devient le 2, et ainsi de suite. Par contre, les opérations arithmétiques font autrement. Une instruction d'addition, par exemple, lit les opérandes dans le registre 1 et 2, puis place son résultat dans le registre 2 (les deux opérandes sont dépilées). Puis, les registres sont décalés d'un rang pour que ce registre 2 devienne le 1.
Les registres ne sont en réalité pas décalés, il n'y a pas de transferts entre registres. A la place, les 16 premiers registres utilisaient une forme limitée de renommage de registres, similaire au banc de registre tournant utilisé sur les processeurs EPIC comme l'Itanium. Les numéros de registres sont renommés, décalés, à chaque fois qu'on empile ou dépile une opérande. Pour faire le renommage de registres, processeur contient un register stack counter de 4 bits, qui est incrémenté à chaque fois qu'on empile une donnée, et est décrémenté quand on dépile une donnée. Quand on veut accéder à un opérande, le numéro de registre architectural associé est traduit en numéro de registre physique, en additionnant le register stack counter (modulo 16, pour rester sur 4 bits).
L'accélération de l'interprétation/JIT : le Thumb-EE d'ARM
[modifier | modifier le wikicode]Quelques processeurs ont ajouté des instructions pour faciliter le travail des interpréteurs/JIT. Par exemple, en 2005, ARM a ajouté le mode Thumb Execution Environment, qui faisait cela. Il reprenait le jeu d'instruction compact thumb et ajoutait quelques instructions et modifiait le comportement d'instructions existantes. Le jeu d'instruction Thumb-EE a été ajouté sur les CPU ARM en 2005, mais a été déprécié en 2011, par manque d'utilité. Mais la tentative mérite qu'on s'attarde dessus. Les CPU de gamme M, déstinés à l'embarqué, n'ont jamais supporté Thumb-EE, vu que de tels processeurs ne sont pas conçus pour exécuter du code interprété/JIT.
Le mode Thumb-EE est un mode d'exécution, séparé de l'ARM normal. Ainsi, une instruction Thumb-EE et une instruction ARM peuvent avoir le même encodage en binaire, mais se comporter différemment. Le processeur est à tout moment soit en mode Thumb-EE, soit en mode ARM normal, ce qui précise comment ces instructions doivent se comporter. Des instructions ont été ajoutées par Thumb-EE, pour qu'il fasse son travail. L'entrée et la sortie du mode Thumb-EE se fait avec deux instructions : ENTERX et LEAVEX. Une fois en mode Thumb-EE, le décodage des instructions thumb se fait avec les règles du Thumb-EE, et non celles du thumb normal.
Une première différence entre thumb normal et Thumb-EE est l'ajout de ce qui s'appelle un null check pour les instructions mémoire. Le null check est utilisé pour les instructions en mode d'adressage "base + indice" ou "Base + décalage", ou tout autre mode d'adressage avec un registre de base utilisés dans des calculs d'adresse. L'idée est de vérifier si l'adresse de base vaut zéro ou non. Si c'est le cas, le processeur lève une exception matérielle, qui est traitée par l'interprétation ou le compilateur JIT.
Une telle situation est en effet signe d'une erreur d'adressage mémoire, qui doit être traitée par l'interpréteur. Typiquement, c'est signe qu'un pointeur n'a pas bien été initialisé, ce qui peut arriver avec du code interprété ou JIT. Les interpréteurs/JIT traitent généralement la situation en ajoutant un test avant toute instruction mémoire, pour vérifier si le pointeur accédé vaut zéro ou non. Avec Thumb-EE, pas besoin d'ajouter les tests en questions : ils sont réalisés automatiquement lors de chaque instruction mémoire.
- Les registres pour la pile, comme le pointeur de pile et le pointeur de frame, sont aussi concernés.
L'instruction CHKA vérifie que les accès à un tableau ne débordent pas en dehors du tableau en question. Par exemple, pour un tableau de 1024 éléments, elle vérifie si l'indice est compris entre 0 et 1024. Pour cela, elle vérifie si l'indice de tableau est bien dans l'intervalle adéquat et lève une exception matérielle si ce n'est pas le cas. Son utilité se comprend quand on sait que tous les accès à un tableau sont vérifiés par l'interpréteur. L'interpréteur est censé ajouter des instructions pour vérifier les indices, à savoir deux ou trois branchements. Avec CHKA, le test se fait en une seule instruction.
D'autres modifications mineures de l'encodage des instructions sont aussi présentes, ainsi que des modifications pour les instructions LOAD/STORE. Précisément, dans les modes "base + indice" et "base + décalage", le décalage et l'indice subissent maintenant des décalages. L'idée est de simplifier le calcul d'adresse. De quoi économiser quelques instructions lors des calculs d'adresse, ce qui facilite le travail de l'interpréteur/JIT. Et des instructions LOAD/STORE précises voient leur comportement modifié pour faciliter la gestion de la pile de Java ou l'exécution des méthodes locales.
Pour faciliter le travail de l'interpréteur, deux instructions HBP et HBLP ont été ajoutées. Elles branchent vers une fonction qui gère les exceptions logicielles (une fonctionnalité présente dans de nombreux langages, comme Java). La première effectue un branchement vers la fonction en question, la seconde sauvegarde l'adresse de retour avant de faire ce branchement. Les deux peuvent brancher vers 256 fonctions pré-déterminées, sans avoir à présenter leur adresse.
La traduction binaire accélérée par le matériel
[modifier | modifier le wikicode]Plus haut, nous avons surtout parlé des compilateurs et des interpréteurs. Cependant, nous devons aussi parler de la traduction binaire. Elle traduit un programme écrit dans un code machine vers un autre code machine. Par exemple, elle traduit un programme compilé pour un CPU x86 vers un code machine ARM. En général, le système d'exploitation est généralement compilé pour le jeu d'instruction natif, mais il exécute des applications prévues pour le x86. Les applications sont traduites par le système d'exploitation, avant d'exécuter le code traduit.
- Nous parlerons dans la suite de code machine source et de code machine cible, pour parler respectivement du code à traduire et du code obtenu après traduction. Idem avec d'autres termes comme architecture cible/source, ou jeu d'instruction cible/source.
La traduction binaire est surtout utilisée pour des questions d'émulation ou de compatibilité. Par exemple, lorsque les Macintosh sont passés de processeurs Power PC vers des processeurs x86, le système d'exploitation Mac OS utilisait la traduction binaire pour convertir les anciennes applications Power PC vers du code x86. Les utilisateurs n'y ont vu que du feu. Le système de traduction binaire était appelé Rosetta 1. Par la suite, lors de la transition de processeurs x86 vers des processeurs Apple, Rosetta 2 a vu le jour.
La traduction binaire a surtout été utilisée pour traduire du code x86 vers un autre jeu d'instruction. Il faut dire que le x86 est le jeu d'instruction dominant. De nombreuses entreprises ont eu pour ambition de briser l'hégémonie du x86 sur PC, en remplaçant le x86 par un jeu d'instruction plus performant, tout en gardant une compatibilité maximale. La traduction binaire était la seule solution pratique. Elle était le plus souvent intégralement réalisée en logiciel, comme c'était le cas sur les architectures Itanium avec le IA-32 Execution Layer.
Les généralités sur la traduction binaire assistée en matériel
[modifier | modifier le wikicode]L'architecture source est presque tout le temps une architecture CISC, assez ancienne, qu'on souhaite émuler. Le choix de l'architecture cible se porte souvent sur une architecture VLIW, fort différente de l'architecture source, et ce pour obtenir une économie de matériel conséquente. Pour s'exécuter rapidement, le code traduit doit exploiter le parallélisme d'instruction, à savoir exécuter plusieurs instructions en même temps dans des unités de calcul séparées. Il est possible d'utiliser un processeur superscalaire avec exécution dans le désordre pour cela, mais au prix d'un cout important en transistors. Alors qu'en utilisant un CPU VLIW, c'est le traducteur binaire qui fait tout le travail d'extraction du parallélisme d'instruction. Et après tout, quitte à avoir un traducteur binaire, autant lui refiler le boulot d'optimisation.
Compiler du code à la volée est certes assez lourd, mais qu'il existe des algorithmes efficaces pour regrouper des instructions indépendantes dans une seule instruction VLIW. Au passage, le processeur VLIW a plus de registres que le processeur source. Cela permet de faire du renommage de registres directement en logiciel. Le traducteur binaire n'hésite pas à changer les noms de registres entre instructions source et instruction VLIW cible, afin de supprimer des dépendances de données. En conséquence, cela ouvre des opportunités de parallélisme, qui sont exploitées lors du regroupement des opérations en instructions VLIW.
Vous pourriez penser que le choix d'un CPU VLIW pour émuler du CISC est tout sauf optimal. Vous devez penser que la traduction binaire est d'autant plus simple que l'architecture source et cibles sont semblables. Dans les faits, ce n'est pas tellement traduire les instructions qui pose problème, mais plus la gestion du registre d'état, des exceptions matérielles, de la mémoire virtuelle, la différence entre gros-boutisme et petit-boutisme, de même que des différences d'adressage pour les périphériques.
Le premier problème est la gestion des conditions, notamment en présence d'un registre d'état. En pratique, la traduction binaire s'utilise pour traduire du code CISC vers du code RISC ou VLIW. Les architectures source ont donc un registre d'état, qui est mis à jour non seulement par des instructions de test, mais aussi des instructions arithmétiques. L'architecture cible n'a elle pas de registre d'état, mais des registres à prédicats. La traduction de l'un vers l'autre est alors quelque peu compliquée.
Et elle est d'autant plus compliquée que le registre d'état est la source de dépendances d'instruction implicites, qui réduisent les performances. Par exemple, si une instruction arithmétique modifie le registre d'état, cela peut impacter l'exécution d'une instruction ultérieure, qui lit ce registre d'état. Pour éliminer ces fausses dépendances, le traducteur binaire doit renommer ce registre en logiciel et détecter les dépendances utiles. Et le compilateur doit gérer les cas où il y a beaucoup de distance entre les deux, voire les cas où l'instruction dépendante n'a pas encore été analysée par le compilateur.
Un autre problème est lié à la gestion des exceptions matérielles, et précisément des exceptions précises. Pour rappel, une instruction VLIW regroupe plusieurs opérations, opérations qui correspondraient à une instruction machine sur un CPU pas VLIW. En conséquence, plusieurs instructions du langage machine "source" sont regroupées en une seule instruction VLIW. Et il faut tenir compte du cas où une instruction source lève une exception. Dans le code VLIW, cela signifie qu'une opération lève une exception, et il faut annuler partiellement l'instruction VLIW associée. Et par partiellement, on veut dire que seules les opérations suivantes dans l'ordre du programme source doivent être annulées, puis ré-exécutées. Et c'est un sacré casse-tête !
Les processeurs VLIW utilisent comme solution le mécanisme d'exceptions différées des processeurs EPIC, vu il y a quelques chapitres. Pour résumer, le code est exécuté par blocs d'instruction, délimités par des branchements ou tout autre limite/barrière pertinente dans le code. Un bloc de code est compilé en deux versions : une version rapide sans exceptions matérielles, une version lente qui gère les exceptions précises. La version rapide s'exécute sans exécuter les exceptions. Cependant, les exceptions matérielles sont enregistrées, pour être prises en compte à la toute fin du bloc de code.
La version rapide est exécutée en premier et elle mémorise si une exception matérielle a eu lieu. Puis une instruction vérifie si une exception a eu lieu et décide quoi faire. Si aucune exception n'a eu lieu, elle passe à la suite du programme, les résultats du bloc de code sont définitivement acceptés. Mais si une exception a eu lieu, tout est annulé. Le processeur est remis dans l'état initial, puis le code est ré-exécuté instruction par instruction de manière à gérer l'exception correctement.
La traduction par pré-décodage
[modifier | modifier le wikicode]Une première solution serait de faire la traduction binaire dans le cache d'instruction. Lors d'un défaut de cache, le code chargé depuis la RAM est traduit en code machine cible. Et c'est ce code machine cible qui est mémorisé dans le cache d'instruction et exécuté par le processeur.
La technique marche sur le papier et n'est qu'une amélioration des techniques de pré-décodage vues il y a quelques chapitres. Cependant, le code obtenu est une traduction assez basique, qui n'incorpore pas d'optimisations dignes de ce nom. De plus, elle gère mal le cas où la taille du code source et cible sont potentiellement très différentes. Concrètement, elle est surtout utile pour traduire du code d'un processeur RISC vers un autre processeur RISC, dont les tailles d'instructions sont similaires. Elle ne permet pas d'émuler plusieurs jeux d'instructions différents, le cout en matériel (un circuit de pré-décodage par jeu d'instruction) serait trop important.
Le projet DAISY d'IBM
[modifier | modifier le wikicode]De très rares processeurs étaient conçus pour accélérer cette traduction binaire, afin de garder de bonnes performances. Les premiers à avoir étudié l'idée étaient IBM, avec leur projet DAISY (Dynamically Architected Instruction Set from Yorktown). Le projet de base était de convertir à la volée du code compilé pour des CPU Power PC, vers du code VLIW. Les chercheurs d'IBM avaient développé un algorithme de traduction dynamique efficace, ainsi qu'un processeur VLIW disposant d'optimisations spécifiques à la traduction binaire dynamique, à la volée. De nombreuses idées de ce projet ont été reprises ou re-découvertes par la société Transmetta, avec ses processeurs Crusoe et Efficieon, puis par NVIDIA avec son projet Denver.
Les ingénieurs de ce projet ont étudié la possibilité d'émuler plusieurs jeux d'instructions différents sur un même processeur, notamment le s390 d'IBM et le x86. En théorie, cela demande juste d'avoir plusieurs programmes de traduction binaire : un pour le x86, un autre pour le s390, éventuellement un autre pour le Power PC. Mais cela ne s'est pas concrétisé. Mais les ingénieurs ont étudié quelle pourrait être l'architecture VLIW idéale pour ça. Par exemple, il fallait des additions 3-opérandes pour simplifier les calculs d'adresse. Une difficulté était la gestion du registre d'état, dont les bits ne sont pas mis à jour de la même manière sur le x86 et le s390 ou le Power PC. La différence entre gros-boutisme et petit-boutisme était aussi un problème, de même que des différences d'adressage pour les périphériques.
Pour que la traduction binaire soit efficace, le processeur VLIW intègre diverses optimisations, comme les exceptions différées et les branchements multi-voies. La principale est la suivante : seul le code des boucles ou fonctions exécutées fréquemment est traduit en code VLIW, le reste du code est interprété. En effet, traduire du code binaire prend plus de temps qu'une simple interprétation. Pour du code qui ne sera exécutée qu'une seule fois, il est plus rapide d'utiliser l'interprétation. Par contre, pour du code exécuté beaucoup de fois, le cout de la traduction binaire est amorti, dilué sur N exécutions, compensé par le gain en temps d'exécution de ce code traduit. Au final, cela permet de ne traduire que le code qui le mérite.
La détection du code fréquemment exécuté est réalisée dans le cœur VLIW. L'unité de branchement mémorise les derniers branchements rencontrés et le nombre de fois qu'ils ont été exécutés. Si ils ont été exécutés un certain nombre de fois, l'unité de branchement lève une exception matérielle, qui invoque le traducteur binaire. La routine de cette exception est le traducteur binaire proprement dit. En clair, la traduction binaire est démarrée quand le processeur détecte qu'une fonction a été exécutée plus de N fois, via une exception matérielle dédiée.
La mesure du nombre d'exécution d'un branchement se fait dans l'unité de calcul dédiée aux branchements, avec l'aide d'une sorte de branch adress buffer modifié. Pour rappel, le branch adress buffer mémorise le Program counter de chaque branchement récemment rencontré. Ici, chaque entrée du branch adress buffer est associée à un compteur incrémenté à chaque exécution du branchement.
Les processeurs Crusoe et Efficieon de Transmetta
[modifier | modifier le wikicode]Les processeurs Crusoe Et Efficeron sont deux processeurs VLIW produits par la société Transmetta, une société californienne rachetée par NVIDIA. Ils étaient conçus pour exécuter spécifiquement des programmes x86, système d'exploitation inclus. L'idée derrière ce projet était d'exécuter du code x86 sans que les applications, ni même le système d'exploitation et le BIOS soient au courant !
Les processeurs Transmetta étaient en réalité conçus pour exécuter un programme unique, le Code Morphing Software (CMS), qui traduisait le code x86 en code VLIW. Transmetta ne voulait pas que son processeur VLIW soit exploité directement, sans le CMS. Il n'avait pas rendu public de compilateur pour traduire du C vers du code VLIW, il n'avait pas donné la documentation du jeu d'instruction VLIW. Des efforts de rétro-ingénieurie, documentés sur le site realworldtech, ont cependant permis de comprendre comment étaient encodées les instructions du processeur Crusoe.
Comme pour le projet Daisy, le Code Morphing Software utilisait à la fois interprétation et traduction binaire, selon les besoins. Les instructions sont interprétées lors de leurs premières exécutions, mais le CMS bascule sur de la traduction en code VLIW après un certain nombre d'exécution. Ainsi, les boucles souvent exécutées sont traduites en code VLIW, alors que le reste du code est interprété. Le choix du code à traduire est le fait du CMS, il utilise des heuristiques complexes pour, qui ne sont pas connues dans le détail.
Le Code Morphing Software est mémorisé dans une EEPROM, ce qui en fait un firmware situé en dessous du BIOS. Le CMS démarre ensuite le BIOS, qui lui-même démarre le système d'exploitation, qui lui-même démarre les pilotes de périphériques et les programmes. Le CMS se réserve les 16 premiers mébioctets de l'espace d'adressage. L'EEPROM du CMS est mappée dedans, mais n'en utilise que la moitié. Le reste est utilisé comme cache, pour mémoriser le code VLIW traduit par le CMS.
Les deux processeurs intégraient aussi deux mémoires SRAM, utilisées par le CMS, appelées la local program memory (LPM) et la local data memory (LDM). La première contient du code qui gére les interruptions, la mémoire virtuelle, les exceptions matérielles, les problèmes d'alignement mémoire, et quelques fonctions très fréquemment utilisées par le CMS. La seconde est de la mémoire RAM utilisée par ces fonctions de la local program memory. Les mémoires caches L1 et L2 sont séparées de ces deux mémoires.
Le processeur Crusoe et Efficieon étaient des processeurs VLIW très simples. Ils n'avaient même pas de MMU, ni de fonctionnalités importantes sur les CPU x86. Pour Crusoe, les instructions VLIW étaient encodées sur 64 ou 128 bits, et regroupaient entre 2 à 4 opérations. Les instructions LOAD/STORE ne supportaient pas d'adressage indicé, juste de l'adressage indirect à registre. Crusoe avait 5 unités de calcul : deux ALU entières, une unité LOAD/STORE, une unité de branchement, une FPU. Efficieon doublait le nombre d'ALU entière et d'unité LOAD/STORE, les instructions VLIW passaient à 256 bits.
Le processeur Crusoe contient 160 registres, dont 64 registres généraux, 32 registres flottants. Les registres généraux font 32 bits, ce qui est cohérent avec le fait que les CPU x86 de l'époque étaient des processeurs 32 bits. Quant aux registres flottants, ils faisaient 80 bits, ce qui colle avec la taille des registres flottants de la FPU x87 utilisée à l'époque. Sur les 64 registres généraux, seuls 48 étaient réellement utilisable pour mémoriser des opérandes. Une partie des registres généraux étaient utilisés pour la gestion de la pile, d'autres pour mémoriser l'état du CPU x86 émulé, un registre était un registre zéro non accesible en écriture.
La gestion des exceptions est optimisée avec un système d'exceptions différées, le même que celui décrit plus haut. Le processeur utilise un système similaire, pour effectuer des lectures anticipées, qui a été expliqué dans le chapitre sur les processeurs VLIW/EPIC. Sauf qu'il s'agit là d'une optimisation pour gagner en performances en général, pas une aide utile pour gérer des exceptions précises ou toute autre subtilité de la traduction binaire.
Pour gérer les exceptions différées et les lectures anticipées, le processeur mémorise l'état du processeur avant de démarrer un bloc de code. Pour cela, le processeur contient des shadow registers, 48 registres entiers et 16 registres flottants. Vous remarquerez qu'il y a autant de registres entiers que de shadow registers entiers. Les registres entiers/flottants sont mémorisés dans les shadow registers avant d'exécuter un bloc de code, afin que le processeur puisse revenir à l'état de base.
Une autre fonctionnalité liée est le gated store buffer. Le principe est d'éviter toute écriture en mémoire RAM, tant que le code VLIW n'a pas émis d'instruction commit. L'idée est que les écritures sont conservées dans la store queue de l'unité LOAD/STORE. Pour rappel, la store queue met en attente les écritures soit tant que les écritures ne sont pas terminées, soit tant que la RAM est occupée. L'idée est que les écritures sont accumulées dans la store queue tant que l'instruction commit n'a pas eu lieu. Le store queue est "déversé dans la RAM" seulement quand l'instruction commit s'exécute.
Les CPU NVIDIA Denver
[modifier | modifier le wikicode]Nvidia a tenté quelque chose de similaire avec son projet Denver. NVIDIA a racheté l'entreprise Transmetta, ses brevets, et a tenté de refaire la même chose. Le projet initial était de traduire du code x86 vers un jeu d'instruction VLIW propriétaire, comme l'a fait Transmetta. Mais NVIDIA n'a pas réussi à acquérir la licence du jeu d'instruction x86 et s'est rabattu sur le jeu d'instruction ARM. Le processeur né de ce projet est le Tegra K1-64 CPU.
Il s'agit d'un processeur composé d'un cœur VLIW, couplé à deux décodeurs d'instructions ARM. Les deux décodeurs ARM traduisent une paire d'instructions ARM consécutives en instructions VLIW, pour les exécuter sur le cœur VLIW. Le CPU peut fonctionner selon deux modes : ARM et VLIW. En somme, le processeur supporte deux jeux d'instruction : le jeu d'instruction ARM, et un jeu d'instruction VLIW. En mode ARM, les instructions ARM sont chargées depuis le cache, traduites en VLIW par les décodeurs ARM, puis exécutées par le cœur VLIW. En mode optimisé, le CPU exécute des instructions VLIW chargées depuis le cache d'instruction, les décodeurs ne sont pas utilisés.
- Le CPU supporte en réalité deux jeux d'instruction ARM : ARM8 et 7. Mais ils correspondent au même mode pour le CPU.
Un point important est que le processeur peut changer très rapidement de mode, en à peine quelques cycles d'horloges. On n'est clairement pas dans le cas des CPU x86, qui mettent des plombes pour passer du mode 32 à 64 bits et inversement. La commutation est tellement rapide qu'on peut considérer que le processeur supporte deux jeux d'instruction simultanément. Et le support simultané de deux jeux d'instruction facilite l'implémentation de la traduction binaire.
Encore une fois, inutile de traduire du code qui ne sera exécutée qu'une seule fois, mieux vaut l'exécuter directement dans le mode ARM. Surtout que le processeur supporte un mode ARM qui permet d'exécuter du code ARM sans perte de performance. Par contre, le code fréquemment utilisé, notamment dans des boucles critiques, est traduit en VLIW. Au final, les performances sont optimisées, en limitant le travail du logiciel traducteur binaire.
La détection du code fréquemment exécuté se fait différemment que pour les processeurs Transmetta et Daisy. L'unité de branchement ne détecte pas directement le code beaucoup exécuté, même si elle a un rôle à jouer. A la place, la détection est réalisée en logiciel, par un thread dédié, qui s'exécute sur un cœur séparé. Il détecte le code fréquemment utilisé en regardent les branchements exécutés récemment. Pour cela, le processeur mémorise l'historique des branchements pris récemment et copie cette historique dans une mémoire tampon dédiée, partagée entre tous les cœurs. Vu qu'elle est partagée, le thread de détection a accès à l'historique sans pertes de performances, seule la copie de l'historique dans ce tampon a un cout en performance, pas son partage.
Le code VLIW obtenu après traduction binaire est mémorisé dans la mémoire RAM, dans une portion spécialement réservée pour. Elle fait 128 mébioctets, et est appelée le cache d'optimisation par NVIDIA. Le cache d'optimisation est protégé en écriture et seul le traducteur binaire peut écrire dedans. Au passage, le traducteur binaire est du code VLIW, ce qui fait qu'il est placé dans le cache d'optimisation.
Le processeur détecte automatiquement quand une fonction pour laquelle le code compilé équivalent est disponible. Pour cela, l'unité de chargement contient une table de correspondance entre l'adresse de la fonction ARM, et l'adresse de son équivalent compilé. Quand une fonction est appelée, l'unité de chargement regarde l'adresse de destination du branchement. Si l'adresse est dans cette table, elle récupère l'adresse de la fonction compilée et branche vers celle-ci.
Le processeur dispose d'un cache d'instruction de 128 kibioctets, ce qui est très important, mais nécessaire vu la taille des instructions VLIW. Le coeur VLIW dispose de ses propres décodeurs, d'un scoreboard, de 7 unités de calcul et de registres. Pour les unités de calcul, il a deux FPU, deux ALU, une unité de branchement, et deux unités LOAD-STORE faisant aussi ALU. Un point important est que le CPU dispose de 64 registres entiers et de 64 registres flottants. C'est deux fois plus que supporte le jeu d'instruction ARM. En mode ARM, seule la moitié des registres est utilisée. Mais en mode optimisé, le traducteur binaire utilise bien les 64 registres, grâce à une sorte de renommage de registres logicielle.
Pour simplifier la traduction binaire, le processeur supporte les techniques vues précédemment. Le processeur intègre un gated store buffer similaire à celui des processeurs Transmetta. Les techniques d'exceptions différées sont aussi supportées, comme sur les processeurs Transmetta. Il a aussi une unité de préchargement décrite par NVIDIA comme agressive, avec support du préchargement de type anticipé (runahead).
L'émulation de la mémoire virtuelle
[modifier | modifier le wikicode]Pour finir, il faut parler de la mémoire virtuelle avec la traduction binaire. Sur le processeur source, le processeur gère à la fois des adresses physiques et virtuelles et sa MMU fait la conversion entre les deux. Sur le processeur VLIW, la MMU est simulée par le programme de traduction binaire, partiellement ou totalement. L'implémentation exacte varie suivant que l'on parle du projet Daisy, des processeurs Transmetta ou de Denver.
Sur les processeurs Transmetta, il n'y a pas de MMU ni de mémoire virtuelle. A la place, le programme de traduction binaire émule la mémoire virtuelle du processeur source. Le processeur VLIW ne gère que des adresses physiques, rien d'autre. L'espace d'adressage physique du processeur VLIW a la même taille que celui du processeur émulé, ici des processeurs x86. Il est vraisemblable que les adresses physiques utilisées par le processeur x86 sont les mêmes que celles du processeur VLIW.
Sur le projet Daisy, le processeur VLIW gère la mémoire virtuelle via pagination, comme les processeurs émulés. Ce qui fait qu'il gère un espace d'adressage virtuel et un espace d'adressage physique. Pour éviter toute confusion, nous parlerons d'adresse physique/virtuelle VLIW pour les adresses physiques/virtuelles du processeur VLIW, d'adresse virtuelle/physique source pour celle du jeu d'instruction traduit, à savoir du Power PC ou du s390, éventuellement de l'x86.
Il faut alors faire le lien entre adresses physiques source et adresses virtuelles VLIW. Pour cela, rien de plus simple : il y a correspondance parfaite. L'adresse physique source numéro N correspond à l'adresse virtuelle VLIW numéro N. Cependant, cela signifie que tout l'espace d'adressage virtuel VLIW serait utilisé par le code à traduire. En réalité, il faut ajouter de la place pour le code traduit et le programme de traduction binaire. La conséquence est que l'espace d'adressage virtuel VLIW est plus large que l'espace d'adressage source.
L'espace d'adressage virtuel VLIW est découpé en trois sections : une section pour le code Power PC à traduire, une autre pour le code traduit en VLIW, et une dernière réservée au traducteur binaire. Le programme de traduction est placé dans une ROM mappée en mémoire dans la seconde section. Le reste de la seconde section est réservé à une mémoire RAM utilisée par le programme de traduction.

Il faut noter que chaque page physique source, une fois traduite, correspond à N pages virtuelles VLIW. La raison est que le code traduit prend plus de place que le code machine originel. En conséquence, on doit utiliser une page finale plus grande. Pour le reste, des pages contiguës en mémoire physique source sont elles aussi contiguës en mémoire virtuelle VLIW. Le calcul d'adresse est donc simple : l'adresse physique source est multipliée par N, puis on ajoute l'adresse de base à laquelle commence la section pour le code traduit.
La traduction binaire sur les Burroughs B1700
[modifier | modifier le wikicode]Nous venons de voir comment accélérer en matériel la traduction binaire, avec des techniques bien spécifiques. Le Burroughs B1700 a procédé autrement et il est intéressant d'étudier en détail son architecture. Son architecture est décrite dans le livre "Interpreting Machines : Architecture and Programming of the Bl700/Bl800 Series", écrit par Elliott I. Organick et James A. Hinds. Une partie de ce qui vfa suivre est un très court résumé de ce livre.
- Dans ce qui suit, nous parlerons de traducteur binaire pour parler soit d'un interpréteur pour un langage de programmation de haut niveau, soit pour un logiciel de traduction binaire.
Le Burroughs 1700 est un processeur 24 bits, qui a cependant la capacité d'émuler des architectures 8, 16 bits assez simplement, via divers mécanismes. Le Burroughs 1700 incorpore de nombreuses optimisations pour cela. Une partie d'entre elles permet de gérer des opérandes de taille différente de 24 bits, d'autres permettent de gérer des instructions de taille différente de 24 bits, d'autres servent pour les deux. Il incorpore aussi des instructions facilitant le découpage des instructions machines en opcode, adresses et constantes immédiates.
L'ALU de taille variable
[modifier | modifier le wikicode]Les Burroughs B1700 avaient une ALU de taille variable, ce qui veut dire qu'elle pouvait faire des calculs sur un nombre de bits compris entre 0 et 24. Pour cela, les calculs étaient faits avec une ALU de 24 bits, puis les bits de poids fort inutiles étaient masqués. L'ALU de 24 bits pouvait masquer les bits de poids fort du résultat, mais avec des limitations. Elle gérait des opérandes et résultats de 0, 4, 8, 12, 16, 20 et 24 bits. Pour avoir un réglage plus fin, une ALU 4 bits séparée permettait de corriger le résultat.
Les registres X et Y étaient reliés à une ALU de 24 bits capable d'effectuer des opérations arithmétiques basiques. Fait étonnant, l'ALU faisait tous les calculs en même temps et fournissait ses résultats dans 7 registres : un registre SUM pour l'addition, un registre DIFFERENCE pour la soustraction, et un registre par opération bit à bit. Les registres pour les opérations bit à bit sont : CMPX et CMPY pour l'opération NOT sur chaque opérande, XANY pour le ET bit à bit, XEOY pour le XOR, XORY pour le OU logique. Les additions et soustractions pouvaient se faire sur des opérandes codés en binaire ou en BCD, suivant le code opération utilisé.
A cela, il fallait ajouter deux registres de "résultat" MSKX et MSKY. Ils fournissaient un masque dépendant de la taille du résultat en bits. C'est ce registre qui permettait de gérer des données de 0, 4, 8, 12, 16, 20, et 24 bits, alors que l'ALU faisait 24 bits. Le masque dans ce registre pouvait servir d'opérande dans une opération de masquage ultérieure, pour corriger le résultat.
L'ALU prenait en opérande les registres X et Y, mais aussi un registre CYF pour la retenue entrante. En sortie, il fournissait aussi deux retenues sortantes : une pour l'addition et une autre pour la soustraction. Elles étaient mémorisées dans les registres CYL et CYD, respectivement. L'ALU fournissait aussi 12 conditions, qui alimentait un registre d'état. Le registre d'état était segmenté en trois registres appelés XYST, XYCN et BICN.
Les registres X et Y sont des registres pour les opérandes, mais il y a aussi deux autres registres de 24 bits. Il s'agit des registres T et L, qui sont des registres généraux. Les registres T et L étaient découpés en 6 sous-registres de 4 bits, nommés LA LB LC LD LE et LF pour le registre L, TA TB TC TD TE et TF pour le registre T. Ils étaient adressables comme n'importe quel registre.
Le processeur contenait de nombreux autres registres de 4 bits, dont les sous-registres de T et L. En tout, il y a 27 registres de 4 bits adressables ! Et le processeur disposait d'une ALU 4 bits pour manipuler ces registres de 4 bits. L'ALU 4 bits est capable de faire des MOV entre registres de 4 bits, des opérations bit à bit, et peut aussi tester si un bit vaut 0 ou 1. La dernière possibilité est très utile pour implémenter les branchements. De plus, cela permettait de faire des opérations de masquage, notamment pour appliquer un masquage supplémentaire à celui de l'ALU. Par exemple, pour gérer des opérations sur 14 bits, on effectue une opération sur 16 bits avec l'ALU, et on masque les 2 bits manquants avec l'ALU 4 bits.
Une architecture bit-adressable
[modifier | modifier le wikicode]Le CPU Burroughs B1700 était bit-adressable. En clair, il pouvait adresser la mémoire bit par bit, si nécessaire. Un avantage est que cela facilitait la traduction binaire des instructions machine, mais nous détaillerons cela plus bas. Un autre avantage est que cela permettait d'émuler des architectures dont la taille des registres/byte/mots était très différente. Et cet avantage mérite quelques explications immédiates.
Le processeur était un CPU 24 bits, mais il pouvait émuler des processeurs 16 bits, 8 bits, 4 bits, 1 bit, 9 bits, ou toute autre valeur. Pour cela, le processeur lisait des mots de 24 bits et utilisant un barrel shifter pour sélectionner les bits adéquats. Par exemple, pour un accès de 16 bits, la donnée lue en mémoire était masquée de manière à ne garder que les 16 bits de poids faible. Le barrel shifter/circuit de masquage était placé directement avant le bus mémoire, sur le trajet des données/instructions. Le barrel shifter était commandé par un registre Field Unit, qui précisait quelle était la taille des données à charger.
Les instructions source , à traduire, peuvent faire 16 bit avec tel jeu d'instruction, 8 bits avec un autre, 32 bits sur un autre, etc. Elles peuvent aussi être de longueur fixe ou variable ! Le B1700 utilisait une architecture bit-adressable pour gérer ces contraintes. En effet, avec un CPU bit-adressable, les contraintes d'alignement des instructions disparaissent !
Des registres d’interfaçage mémoire adressables
[modifier | modifier le wikicode]Pour communiquer avec la mémoire, il avait trois registres séparés : READ, WRITE, MAR. READ contient la donnée lue lors d'une lecture, WRITE est pour une donnée à écrire lors d’une écriture, MAR contient l'adresse à lire ou en cours de lecture. Il s'agit de registres d’interfaçage mémoire, les mêmes que ceux vus dans le chapitre sur le chemin de données du CPU. Une instruction LOAD est donc émulée en deux instructions machines : une instruction MOV pour copier l'adresse dans le registre READ, une instruction de lecture proprement dite. L'écriture demande d'ajouter une seconde instruction MOV pour copier la donnée à écrire, et la lecture est remplacée par une écriture.
Le fait que ces registres d’interfaçage mémoire et IO soit adressable est peu commun, et c'est plus quelque chose qu'on attend d'un microcode que d'instructions machines. D'ailleurs, l'implémentation des instructions LOAD/STORE ressemble à ce qui est effectué par le séquenceur d'un CPU. Le séquenceur est d'ordinaire celui qui séquence la copie des adresses/données dans les registres d’interfaçage, puis lance la lecture/écriture. Ici, c'est réalisé via des instructions machines. Il faut bien comprendre que les registres d’interfaçage mémoire ont été promus au rang de registres architecturaux, sur ce processeur.
De même, pour communiquer avec les entrées-sorties, il disposait de deux registres CMND et DATA : CMND pour envoyer une commande à une entrée-sortie, DATA pour lire ou écrire une donnée. Il y avait aussi un registre U, qui mémorisait une donnée lue depuis le lecteur de cassette.
Le code machine du Burroughs B1700
[modifier | modifier le wikicode]Le processeur utilise des instructions de 16 bits de long, qui sont copiées dans registre d'instruction M de 16 bits. Le program counter est appelé le registre A. Les instructions du processeur font 16 bits de long, et elles sont alignées en RAM sur 16 bits, ce qui fait que les 4 bits de poids fort de ce registre valent 0, seuls les 14 bits de poids fort sont utiles. Les adresses mentionnées plus haut, qui adressent les micro-opérations en mémoire RAM, sont des adresses de mots de 16 bits, pas les adresses de bit individuels.
Fait intéressant, il était possible de faire un OU logique entre l'instruction lue et un registre du processeur, le résultat étant mémorisé dans le registre M. Cela permettait de faire du code automodifiant, pour émuler des modes d'adressage complexe. Cela permettait par exemple de remplacer une adresse dans une micro-opération. L'adresse est alors surimposée avec un OU dans le champ d'adresse, alors que le reste de l'instruction n'est pas modifié.
Afin de gérer les interruptions, ces dernières étaient partiellement détectées en logiciel. Une interruption doit être prise en compte à la toute fin de l'exécution d'une instruction source, quand celle-ci a fini son travail. Mais vu qu'ici, les instructions sont émulées avec des séries d'instructions cibles, il y a un problème : il est possible d'interrompre une instruction émulée en plein milieu. Pour éviter cela, le processeur ne déclenche pas les interruptions matérielles immédiatement, elles sont mises en attente. Une instruction dédiée vérifie si une interruption est en attente, et l'exécute alors cas échéant. L'instruction en question est utilisée en dernier, dans la suite d'instruction qui émule l'instruction source.
La traduction binaire sur le Burroughs B1700
[modifier | modifier le wikicode]Les Burroughs B1700 intégraient une pile d'adresse de retour, capable de mémoriser 16 adresses de 24 bits, ainsi que 4 registres généraux, nommés X, Y, T et L, tous de 24 bits. Un point important est que le registre T avait des possibilités d’extraction spécifiques. Il était possible d'extraire N bits, placés n'importe où dans ce registre, et de les copier dans un autre registre. Le CPU supportait une instruction EXTRACT pour ça. Nous verrons comment cette possibilité est exploitée pour la traduction binaire dans ce qui suit.
Dans ce qui suit, on suppose que l'instruction est composée de champs : l'opcode, les opérandes, les adresses absolues, les constantes immédiates, les numéros de registre, etc. L'idée est que l'instruction source est chargée dans le registre T, puis découpée en champs, qui sont copiés dans les registres du processeur ou envoyés au microcode. Je rappelle que le registre T est relié à un barrel shifter, différent au précédent, qui permettait de masquer, décaler et extraire une donnée de ce registre. Et il y a la possibilité d'extraire une suite de bit de ce registre T, pour la copier dans un autre registre.
Par exemple, prenons une instruction composée d'un opcode, d'une constante immédiate et d'une adresse immédiate. Premièrement, elle est chargée dans le registre T. Deuxièmement, la constante immédiate est extraite du registre T et copiée dans le registre X. Troisièmement, l'adresse est extraite et est copiée dans le registre d’interfaçage mémoire, une lecture est démarrée, et l'opérande lu est copiée dans le registre Y. Enfin, l'instruction équivalente à l'opcode est exécutée par le microcode. Le tout prend plusieurs instructions : une première pour lire l'opcode, une seconde pour lire le premier opérande, une troisième pour lire la seconde opérande, une quatrième pour tout exécuter.
Notons que suivant la taille des champs, on peut en charger plusieurs à la fois. Par exemple, imaginons qu'on ait chargé 24 bits, contenant un opcode de 8 bits et une constante immédiate de 16 bits : le registre T est découpé en un opcode et une constante, avec deux instructions EXTRACT.
L'architecture est bit-adressable, car elle gère des champs qui ne font pas forcément 8 ou 16 bits. Par exemple, certains jeux d'instructions ont des opcodes codés sur 6 ou 7 bits, voire 9-10 bits. Utiliser une architecture bit-adressable règle ce problème d'alignement. On peut lire un champ depuis la mémoire RAM en précisant l'adresse de son premier bit, pas besoin de gérer des accès non-alignés. Par exemple, prenons une instruction source avec un opcode de 7 bits, suivi par une adresse de 12 bits et une constante immédiate de 15 bits. On peut charger 7 + 12 bits en un premier accès, pour extraite l'opcode et l'adresse, suivi par un second accès de 15 bits pour extraire la constante immédiate.
Pour charger un champ, il faut connaitre plusieurs informations : l'adresse de son premier bit, sa taille. Pour cela, le B1700 contient deux registres, nommés Field Adress (FA) et Field Length (FL), qui indiquent l'adresse du premier bit et la taille de l'instruction à charger. Le premier fait 24 bits, ce qui permet de gérer des adresses de 24 bits. N'oubliez pas que le processeur est bit-adressable, ce qui permet d'adresser 16 Mébi-bits, soit 2 mébioctets. Le registre pour la taille fait lui 16 bits, ce qui permet de gérer des instructions de 65536 bits, soit 8192 octets !
Leur nom commence par field, car le processeur peut gérer des instructions de taille variable et/ou plus longues que 24 bits. Dans ce cas, le chargement de l'instruction se fait champ par champ. Pour donner un exemple, prenons une instruction composée de 20 champs de 12 bits chacun. Les champs sont chargés un par un dans le registre T. Le registre FA est alors initialisé avec l'adresse de l'instruction, il est incrémenté de 12 à chaque cycle. Le registre FL est initialisé avec la taille de l'instruction, soit 20 × 12 = 240 bits, et il est décrémenté de 16 à chaque cycle.
Précisons cependant que la taille d'un champ peut être changée d'un cycle à l'autre. Par exemple, le processeur peut charger un opcode de 8 bits, l'interpréter, détecter que l'instruction demande ensuite de charger une adresse de 16 bits. Dans ce cas, le registre FL est immédiatement altéré pour charger 16 bits, au lieu des 8 bits de l'opcode immédiatement précédent.
Le Burroughs B1726 : le mal-nommé split-level control store
[modifier | modifier le wikicode]Le Burroughs B1700 avait plusieurs modèles : le B1726, le B1710 et le B1800.
- Le B1710 plaquait le traducteur binaire en mémoire RAM.
- Le B1800 avait ajouté un cache dédié au code du traducteur binaire, afin d'améliorer les performances.
- Le B1726 utilisait un local store dédié au traducteur binaire.
Le B1726 peut exécuter le traducteur binaire soit depuis le local store, soit depuis la mémoire RAM avec une pénalité en termes de performances. La documentation appelle cela un split-level control store, mais le terme est trompeur : la documentation qualifie de microcode le langage machine du Burroughs B1700, ce qui est à l'origine de beaucoup de confusions. Une autre source de confusion est que ce langage machine a quelques propriétés qu'on pourrait attendre d'un microcode, mais certaines sont manquantes. Par exemple, il peut adresser les registres d’interfaçage mémoire, mais c'est parce que ce sont des registres architecturaux sur ce processeur. Ses instructions n'encodent pas de signaux de commande, par contre, elles font 16 bits et adressent les opérandes de manière implicite.
Le local store mémorisait au maximum 2048 instructions machine, ce qui fait que les 2048 premières "adresses" correspondaient au local store. Les suivantes étaient des adresses de mémoire RAM qui contenaient le reste du traducteur binaire. En pratique, l'adresse qui séparait les deux était configurable ! On pouvait limiter la taille du local store à un multiple de 32 instructions ! Les adresses restantes étaient alors réattribuées à la RAM. Par exemple, on pouvait attribuer 64 adresses au local store, les 2048 - 64 adresses restantes étaient alors attribuées à la mémoire RAM. Un registre limite, nommé TDPM, indiquait quelle était l'adresse de démarcation entre les deux.
Le programmeur devait découper le traducteur binaire en segments, certains étant placés dans le local store, les autres étant en mémoire RAM. Mieux que ça, le processeur autorisait d'utiliser la technique de l'overlaying pour le microcode ! Il y avait même une fonction OVERLAY dédiée pour ! OVERLAY avait besoin de trois opérandes : la taille du segment à copier, son adresse source en mémoire RAM, son adresse de destination dans la micro-SRAM.
L'interpréteur réservait un "segment" en mémoire RAM, pour les données, dont le processeur connaissait la position. En dehors de ce segment, il n'y a que des instructions machines à traduire, et le traducteur binaire. Le CPU incorporait pour cela deux registres, un pour l'adresse de base du segment de données, un autre pour son adresse de fin. Il incorporait une protection mémoire limitée sur ce segment, à savoir que seul l'interpréteur pouvait lire ou écrire dans ce segment.
Les jeux d'instructions dédiés à un langage de programmation
[modifier | modifier le wikicode]De rares processeurs sont 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, 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.
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 leurs 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é inventées, avant que les concepteurs se rendent compte des défauts de cette approche.
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.
À 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.




