IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

L'approche qualité avec OCaml

Image non disponible


précédentsommairesuivant

XI. Partie X

65. Les familles de processeurs
66. Architecture processeur et performance
67. Le langage machine
68. Le langage source
69. Quelques compléments sur le langage OCaml
70. La traduction en code machine
71. Le traducteur en action
Pour aller plus loin

XI-A. Application à la génération de code machine

 

XI-A-1. 65. Les familles de processeurs

Comme tout composant matériel ou logiciel un microprocesseur comporte une interface et une implémentation :

  • l'implémentation est un interpréteur machine pour le jeu d'instructions, son détail diffère d'un modèle à l'autre et n'intéresse pas l'auteur de compilateurs ;
  • l'interface est un jeu d'instructions, plus ou moins riche d'un modèle à l'autre il reste néanmoins globalement identique tant qu'on s'en tient à une même famille de microprocesseurs.


On distingue trois grandes architectures de jeu d'instructions :

  • les processeurs CISC (Complex Instruction Set Computing) caractérisés par une instruction MOVE, un petit nombre de registres (disons 8 ou 16) et des instructions du genre ADD op,reg où reg est un registre et op un opérande, elle-même étant un registre ou une case mémoire dont l'adresse peut être calculée de façon plus ou moins sophistiquée ;
  • les processeurs RISC (Reduced Instruction Set Computing) caractérisés par une paire d'instructions LOAD et STORE, un grand nombre de registres (disons au moins 32), des instructions de longueur fixe et des mnémoniques simples du genre ADD reg1,reg2,reg3 ;
  • les processeurs VLIW (Very Long Instruction Word) ou EPIC (Explicitly Parallel Instruction Computing) sont identiques aux RISC, mais caractérisés par des instructions riches capables d'effectuer un plus grand nombre de traitements parallèles, conditionnels ou spéculatifs par cycle d'horloge.


Dans la plupart des architectures, le nombre total d'instructions disponibles est élevé et dépasse souvent la centaine.

Ce nombre n'a en fait que peu d'influence, car bon nombre de ces instructions sont en fait dédiées à des opérations sur des données spécialisées (opérations sur les flottants, les chaînes de bits, les vecteurs), à des manipulations des registres spéciaux et des caches, ainsi qu'à des opérations système (du code en mode superviseur) qui ne concernent pas le code en mode utilisateur.
La richesse du jeu d'instruction n'influe donc pas tellement sur la complexité générale du compilateur.

 

XI-A-2. 66. Architecture processeur et performance

Dans un monde idéal la performance serait uniquement le souci du fondeur et il n'y aurait pas à s'en soucier.

Dans la pratique le jeu d'instructions limite le nombre de variables temporaires (registres) sur lesquelles ont lieu les opérations.
Il incombe donc au compilateur de masquer cette limitation de ressources autant qu'il le peut :

  • en effectuant une transformation (register allocation) qui donne l'illusion d'un nombre illimité de variables alors que le nombre de registres est limité ;
  • cette transformation va générer des instructions MOVE ou LOAD / STORE qui ne participent à aucun calcul, mais qui agissent comme un cache pour les variables situées en mémoire ;
  • avec une transformation naïve, on génère un grand nombre de MOVE ou LOAD / STORE qui peuvent représenter 30 % ou même plus de surcharge en temps d'exécution ;
  • une meilleure méthode consiste à modéliser le flot d'exécution pour calculer la durée de vie de chacune des variables (par exemple à l'aide d'une mise sous forme dite SSA pour Single-Static-Assignment) afin de leur assigner un registre pour toute leur durée de vie (au lieu d'une seule opération dans le cas de la transformation naïve) ;
  • ce genre d'optimisation est très efficace puisqu'il supprime une grande quantité de MOVE / LOAD / STORE.


Contrairement à ce que l'on pourrait penser la documentation du constructeur de CPU n'est pas l'élément vital dans la génération d'un code performant :

  • comme le temps d'exécution d'une instruction sur un processeur moderne dépend du modèle considéré, ainsi que d'un ensemble de conditions dynamiques très difficiles à maîtriser (état du pipeline de décodage, disponibilité du code et des données dans les différents caches, hyper-threading) l'auteur de compilateurs s'intéresse moins à la performance quantitative d'une instruction qu'à sa complexité relative par rapport à une instruction qui lui serait équivalente ;
  • il est fondamentalement plus simple de lire un registre que de lire une case mémoire, de faire un décalage à droite que de faire une division par 2, donc dans tous les cas où c'est possible on cherchera à substituer une opération par une autre fondamentalement plus simple ;
  • la plupart de ces optimisations sont locales et par conséquent, relativement faciles à implanter ;
  • l'optimisation difficile c'est l'optimisation globale, celle dont la portée excède celle d'une expression/instruction dans le langage source ;
  • l'optimisation globale la plus payante est celle de register allocation ;
  • la bonne nouvelle c'est que cette optimisation est relativement bien portable d'une architecture de CPU à une autre ;
  • la mauvaise nouvelle c'est que cette optimisation est relativement difficile à implanter et coûteuse en temps d'exécution par rapport à l'approche naïve.
 

XI-A-3. 67. Le langage machine

Choisir un jeu d'instructions pour un tutoriel de compilation est relativement délicat :

  • trop abstrait : le lecteur n'apprendra rien d'immédiatement applicable ;
  • trop réaliste : le lecteur n'apprendra rien de général.


Pour ce tutoriel on a fait le choix pragmatique d'une architecture CISC populaire, mais sans rentrer dans le détail de l'encodage des instructions Intel x86, ceci afin de garder une notation de haut niveau tout en restant proche de la machine réelle.
Toujours dans le même but de ne pas surcharger inutilement un code qui se veut essentiellement éducatif, on s'est limité à une seule taille d'opérande à savoir le mot machine soit 32 bits pour ia32 ou 64 bits pour ia64.
En particulier toutes les tailles et tous les déplacements (offset) sont exprimés en nombre de mots machine.

Finalement, pour rester très concret, on donnera une traduction de notre jeu d'instruction abstrait vers deux architectures CISC populaires.

Parmi tous nos registres, nous en réservons trois pour des usages spécifiques :

  • un premier servira de pointeur de cadre de pile ;
  • un second servira de pointeur d'environnement global ;
  • un troisième registre est sans doute déjà réservé comme pointeur de pile.


Les opérandes couvrent l'ensemble des modes d'adressage qui nous seront directement utiles :

  • Imm n désigne la valeur constante entière n ;
  • Reg n désigne le nième registre ;
  • Main n désigne une variable globale située à n mots machines dans l'environnement global ;
  • Stack n désigne une variable locale située à n mots machines dans le cadre de pile ;
  • Offset(reg,offset) désigne le mot situé à l'adresse reg + offset ;
  • ScaleL(reg0,reg1) désigne le mot situé à l'adresse reg0 + reg1 ;
  • ScaleD(reg0,reg1) désigne le mot situé à l'adresse reg0 + reg1 * 2.
 
Sélectionnez
type asm_op =
  | Imm of int          (* immediate *)
  | Reg of int          (* register *)
  | Main of int         (* global variables *)
  | Stack of int        (* local variables *)
  | Offset of int * int (* record field *)
  | ScaleL of int * int (* access to word array  *) 
  | ScaleD of int * int (* access to dword array *)

Les étiquettes serviront lors des sauts :

 
Sélectionnez
type asm_lab =
  {mutable label: int option}

Ces conditions serviront lors des sauts conditionnels :

 
Sélectionnez
type cond_op =
  | Equal | Less | More  
  | NotEqual | NotLess | NotMore

Les opérateurs binaires, on pourrait en ajouter d'autres cela ne changerait pas notre schéma de traduction :

 
Sélectionnez
type bin_op =  
  | Add | Sub | Mul | Div | Mod | Xor | And | Or

Finalement nos mnémoniques utilisent les opérandes et autres éléments précédemment définis :

 
Sélectionnez
type asm_instr =
  | MoveTo of asm_op * int  
  | MoveFrom of asm_op * int
  | Apply of bin_op * int * asm_op  (* binary operation *)
  | Addr of int * asm_op  (* effective address *)
  | Label of asm_lab      (* declare label *)
  | Branch of asm_lab
  | BranchIf of cond_op * asm_lab
  | Push of asm_op
  | Call of asm_lab
  | CallTo of asm_op
  | Frame of int          (* build stack frame *)
  | ReturnPop of int      (* return and pop arguments *)
  | PushRegs of int
  | PopRegs of int

Où :

  • reg étant un numéro de registre ;
  • op étant un opérande ;
  • lab étant une étiquette ;
  • MoveTo(op,reg) copie le contenu de l'opérande op dans reg ;
  • MoveFrom(op,reg) copie reg dans l'opérande op ;
  • Apply(,reg,op) applique l'opération reg ⊕ op et place le résultat dans reg ;
  • Addr(reg,op) calcule addresse effective op et place le résultat dans reg ;
  • Label lab declare l'étiquette lab ;
  • Branch lab effectue un saut inconditionnel à l'étiquette lab ;
  • BranchIf(cond,asm_lab) effectue un saut conditionnel à l'étiquette lab;S
  • Push op empile le contenu de l'opérande op ;
  • Call lab effectue un appel de sous-routine à l'étiquette lab ;
  • CallTo op effectue un appel de sous-routine à l'addresse effective op ;
  • Frame n créer un cadre de pile d'une taille de n mots machine ;
  • ReturnPop n retourne à l'appelant et dépile les n paramètres réels ;
  • PushRegs n empile tous les registres de 0 à n ;
  • PopRegs n dépile tous les registres de n à 0.


Tout en restant abstrait notre assembleur simule assez bien les processeurs CISC réels comme le montre le tableau suivant qui fait le rapprochement avec deux CPU CISC très populaires.

assembleur abstrait assembleur Intel 80486 assembleur Motorola 68020
Imm 0 0 #0
Reg 0 EAX D0
Main -2 [ESI-8] (-8,A6)
Stack -2 [EBP-8] (-8,A5)
Offset(0,2) [EAX+8] (8,A0)
ScaleL(0,1) [EAX+EBX] (A0,D1.L*4)
ScaleD(0,1) [EAX+EBX*2] (A0,D1.L*8)
MoveTo(op,reg) MOV reg,op MOVE.L op,reg
MoveFrom(op,reg) MOV op,reg MOVE.L reg,op
Apply(Add,reg,op) ADD op,reg ADD.L op,reg
Addr(reg,op) LEA reg,op LEA op,reg
Branch lab JMP lab BRA lab
BranchIf(NotEqual,lab) JNE lab BNE lab
Push op PUSH op MOVE.L op,-(A7)
Call lab CALL lab BSR lab
CallTo op CALL op JSR op
Frame 2 PUSH EBP
MOV ESP,EBP
SUB ESP,8
LINK A5,#8
ReturnPop 1 MOV EBP,ESP
POP EBP
RET 4
UNLK A5
RTD #4
PushRegs 2 PUSH EAX
PUSH EBX
PUSH ECX
MOVEM.L D0-D2,-(A7)
PopRegs 2 POP ECX
POP EBX
POP EAX
MOVEM.L (A7)+,D0-D2

Le fait que notre assembleur abstrait comporte aussi peu d'instructions confirme que les compilateurs, en général, n'utilisent qu'une petite fraction du jeu total d'instruction, ceci pour les raisons déjà évoquées plus haut.
Bien sûr un langage réaliste possède plus de types de données que notre langage jouet qui n'a même pas les nombres flottants. Il y a aussi des opportunités d'exploiter quelques spécificités de l'architecture réelle. Ce ne sont là que quelques extensions et optimisations qui ne changeraient pas le cœur de la traduction qui s'occupe avant tout des expressions simples et des structures de contrôle élémentaires.

 

XI-A-4. 68. Le langage source

On a choisi un langage de programmation impérative structurée de type C/Pascal avec une extension POO.
On en donne ici que l'arbre de syntaxe abstraite issu du front-end et dont on n'a retenu que les informations strictement nécessaires à la traduction dans notre langage machine.

Les variables sont soit simples soit rangées dans un tableau :

 
Sélectionnez
type ast_var =
  | Var of int            (* offset *)
  | Array of int * int    (* offset, size of item *)

À l'aide de ces variables, on peut construire des expressions plus complexes :

  • Unit est l'expression vide ;
  • Integer n est l'entier constant n ;
  • Local n est une variable locale située à n mots dans le cadre de pile ;
  • Glocal n est une variable globale située à n mots dans l'environnement global ;
  • Member(e,c) est le champ c dans l'enregistrement e ;
  • Compare(p,a,b) compare a et b selon le prédicat p ;
  • BinaryOp(,a,b) calcule a ⊕ b ;
  • ItemAt(a,i) est l'élément d'indice i dans le tableau a ;
  • ProcCall(p,a) appelle la procédure p avec la liste d'arguments a ;
  • MethodCall(m,o,a) appelle la méthode m de l'objet o avec la liste d'arguments a.
 
Sélectionnez
type ast_expr =
  | Unit
  | Integer of int
  | Local of ast_var
  | Global of ast_var
  | Member of ast_expr * ast_var 
  | Compare of cond_op * ast_expr * ast_expr
  | BinaryOp of bin_op * ast_expr * ast_expr
  | ItemAt of ast_expr * ast_expr * int   
  | ProcCall of ast_proc * ast_expr list   
  | MethodCall of ast_expr * ast_var * ast_expr list

Les instructions sont déclarées d'une façon assez parlante :

 
Sélectionnez
and ast_instr =
  | Nop
  | Block of ast_instr list
  | AssignTo of ast_expr * ast_expr
  | IfThenElse of ast_expr * ast_instr * ast_instr
  | WhileDo of ast_expr * ast_instr
  | Loop of ast_instr * asm_lab
  | Break of ast_instr
  | Return of ast_proc * ast_expr
  | Ignore of ast_expr

Précisons tout de même que :

  • un Break n'est possible que dans une instruction Loop ;
  • Ignore sert avant tout à convertir un appel de procédure/méthode en une instruction.


Enfin, une procédure est un quadruplet formé :

  • d'une étiquette (adresse de début du code) ;
  • d'une arité (nombre d'arguments) ;
  • d'une taille de cadre local (nombre de mots à allouer sur la pile) ;
  • d'un corps (liste d'instructions).
 
Sélectionnez
and ast_proc =
  asm_lab * int * int * ast_instr

Une méthode ne sera qu'une variable-procédure c'est-à-dire une procédure située à un certain déplacement.

 

XI-A-5. 69. Quelques compléments sur le langage OCaml

Le code de notre traduction utilise quelques notions nouvelles qui n'ont pas été présentées dans les parties précédentes de ce cours OCaml.
Ce paragraphe ne prétend pas présenter ces notions en profondeur, il se contente de survoler la syntaxe des notions utilisées afin que le code ne fasse pas obstacle à la compréhension.

Tout d'abord :

 
Sélectionnez
function pat1 -> val1 | pat2 -> val2 | ... | patn -> valn

Permet d'abstraire un filtrage, comme avec :

 
Sélectionnez
fun x -> match x with pat1 -> val1 | pat2 -> val2 | ... | patn -> valn

Mais sans déclarer la variable x, elle reste anonyme.

Ensuite, comme son nom le laisse supposer, OCaml possède une extension POO.
Cette extension orientée objet mériterait à elle seule un cours complet dont les aspects abordés ici ne seraient qu'un maigre aperçu.

Syntaxiquement, une nouvelle classe dotted peut être déclarée ainsi :

 
Sélectionnez
class dotted arg1 arg2 =
  object
    val plus = arg1 + arg2
    val mutable minus = arg1 - arg2
    method compute = function n -> plus - minus + n  
  initializer
    print_endline "new object created"
  end

Où :

  • arg1 et arg2 sont les arguments du constructeur ;
  • le mot-clé val déclare une nouvelle valeur privée locale à l'objet ;
  • le mot-clé mutable rend possible l'assignation d'une valeur locale ;
  • le mot-clé method déclare une nouvelle valeur publique visible de l'extérieur de l'objet ;
  • la section initializer contient du code d'initialisation exécuté uniquement lors de la création de l'objet.

En fait la déclaration d'une classe est une double déclaration :

  • elle déclare un nouveau type dotted qui est un type d'objet, c'est-à-dire un type enregistrement ouvert (on dit aussi extensible)
 
Sélectionnez
  type dotted = <compute: int -> int>
  • elle déclare une nouvelle fonction new dotted qui renvoie un objet de type dotted
 
Sélectionnez
  # new dotted;;
  - : int -> int -> dotted = <fun>

L'envoi de message se fait à l'aide de l'opérateur #.

L'extension POO de OCaml est compatible avec l'inférence de types et respecte la sémantique fonctionnelle du langage.
En particulier lorsqu'un envoi de message demande la méthode d'un certain objet il obtient en résultat une fonction ordinaire.

 
Sélectionnez
# (new dotted 1 2)#compute;;
new object created
- : int -> int = <fun>

Le constructeur d'une classe est également une fonction ordinaire.

 
Sélectionnez
# new dotted;;
- : int -> int -> dotted = <fun>

Au même titre que n'importe quelle autre fonction, méthodes et constructeurs peuvent être passés en argument ou être renvoyés comme résultat.

La compatibilité entre deux objets découle de la relation d'inclusion de leurs types d'objet respectifs.
La relation d'inclusion entre types d'objet généralise l'héritage tel qu'il existe dans la plupart des langages POO.
La compatibilité entre types d'objet introduit une nouvelle forme de polymorphisme appelé polymorphisme ad hoc par opposition au polymorphisme paramétrique.
Nous n'en dirons toutefois pas davantage sur cet aspect, car nous n'en aurons pas besoin ici.

Enfin, il existe un constructeur de motif que nous n'avions pas rencontré jusqu'à présent.
Le motif noté pat1 | pat2 est appelé un motif union et filtre toute valeur filtrable par le motif pat1 ou le motif pat2.
Bien sûr pat1 | pat2 doivent être de même type. Ils doivent aussi déclarer le même ensemble de variables.
La fonction offset utilise un motif union pour extraire l'offset d'une variable :

 
Sélectionnez
    let offset (Var x | Array(x,_)) = x
 

XI-A-6. 70. La traduction en code machine

Dans un premier temps il nous faut mettre en place une stratégie d'allocation des registres.
Nous allons utiliser un schéma simple et typique dans les compilateurs CISC :

  • un opérateur unaire place le résultat dans le même registre que son argument ;
  • un opérateur binaire calcule son argument gauche dans le registre n, son argument droit dans le registre n + 1, puis place le résultat de l'opération dans le registre n.


La figure ci-dessous illustre l'annotation d'un arbre d'expression selon ce schéma d'allocation. Les opérandes (dans les feuilles) et les opérateurs (dans les nœuds) n'y sont pas mentionnés pour mieux faire ressortir les numéros de registre.

Image non disponible

Le nombre de registres strictement nécessaires à l'évaluation d'une expression sans utiliser la pile est égal au nombre de Strahler de l'arbre d'expression à évaluer.
Ce nombre est relativement petit puisqu'il n'augmente que lorsqu'on ajoute un niveau à un arbre complet. C'est ce qui explique que le jeu d'instruction CISC avec son nombre de registres relativement réduit résiste encore face aux nouvelles architectures à plusieurs centaines de registres.

Certaines opérations et, en premier lieu l'appel de fonction sont susceptibles d'écraser un ou plusieurs registres.
Afin que des résultats intermédiaires ne soient pas perdus, ces opérations doivent éventuellement sauver temporairement des registres sur la pile pour ensuite les restaurer en fin de calcul.

Le squelette de notre traducteur est une classe ast_to_asm dont le constructeur attend une liste de procédures qu'il va traduire en code machine. Ces procédures pourront être récursives ou mutuellement récursives, car notre traducteur effectue la mise à jour automatique des sauts en avant.
La classe ast_to_asm commence par quelques formalités pour :

  • créer la liste d'instructions dans le champ code ;
  • définir label, le numéro de la prochaine déclaration d'étiquette ;
  • déclarer deux routines d'émission de code pour les fonctions et méthodes.

Le code qui nous intéresse vraiment prendra place à l'endroit en pointillés.

 
Sélectionnez
class ast_to_asm proc_list =
  object
    val mutable code : asm_instr list = [] 
    val mutable label = 0
    method asm_code = List.rev code
  initializer
    let emit asm = code <- asm::code in
    let offset (Var x | Array(x,_)) = x
    and emit_call (l,a,f,b) =
      emit (Call l)
    and emit_callto proc =
      match proc with
      | Var offset ->
          emit (Push (Reg 0));
          emit (MoveTo (Offset(0,0),0));
          emit (MoveTo (Offset(0,offset),0));
          emit (CallTo (Offset(0,0)))
      | _ -> assert false
    in
    ...
  end

Le cœur de notre évaluateur est une fonction eval_val reg expr qui place la valeur de expr dans le registre reg.

Cependant ça n'est pas la totalité de notre évaluateur, en effet, si on plaçait toutes les expressions dans un registre ça ne serait plus du CISC, ça serait du RISC !
Il nous faut en plus un évaluateur auxiliaire capable de placer des expressions dans un opérande au lieu d'un registre.
Il nous faut également un évaluateur auxiliaire capable de calculer l'adresse d'une expression plutôt que sa valeur.

En tout nous avons besoin de pas moins de quatre fonctions auxiliaires :

  • eval_arg reg expr place la valeur de expr dans un opérande ;
  • eval_addr reg expr place l'adresse de la valeur expr dans reg ;
  • eval_scale reg disp place le déplacement disp dans un opérande ;
  • eval_push expr place la valeur de expr sur la pile.
 
Sélectionnez
    let rec eval_arg reg = function
      | Integer n -> Imm n
      | Local v -> Stack (offset v)
      | Global v -> Main (offset v)
      | expr -> eval_val reg expr; (Reg reg)
    and eval_push expr =
      emit (Push (eval_arg 0 expr))
    and eval_scale reg = function
      | 1 -> ScaleL(reg,reg+1)
      | 2 -> ScaleD(reg,reg+1)
      | size ->
          emit (Apply(Mul,reg+1,Imm size));
          ScaleL(reg,reg+1)
    and eval_addr reg = function
      | Member(record,var) -> 
          eval_addr reg record;
          emit (Addr(reg,Offset(reg,offset var)))
      | ItemAt(vector,index,size) ->  
          eval_addr reg vector;
          eval_val (reg+1) index;
          emit (Addr(reg,eval_scale reg size))
      | expr ->
          eval_val reg expr
    and eval_val reg = function
      ...

La fonction eval_val applique notre schéma d'allocation de registres sur chacun des noeuds de l'arbre d'expression et émet les instructions appropriées à chacun des cas.
Il n'y a rien de particulier à signaler hormis peut-être l'utilisation du couple d'instructions PushRegs / PopRegs pour conserver les résultats intermédiaires lors des appels de fonctions ou méthodes.

 
Sélectionnez
    and eval_val reg = function
      | Unit ->
          ()
      | Integer n ->
          emit (MoveTo(Imm n,reg))      
      | Local v ->
          emit (MoveTo(Stack (offset v),reg))      
      | Global v ->
          emit (MoveTo(Main (offset v),reg))      
      | Member(record,var) -> 
          eval_addr reg record;
          emit (MoveTo(Offset(reg,offset var),reg)) 
      | Compare(Equal,left,Integer 0) ->
          eval_val reg left
      | Compare(cond,left,right) ->
          eval_val reg left;
          let arg = eval_arg (reg+1) right
          in emit (Apply(Sub,reg,arg))
      | ItemAt(vector,index,size) ->  
          eval_addr reg vector;
          eval_val (reg+1) index;
          emit (MoveTo(eval_scale reg size,reg))
      | BinaryOp(op,left,right) ->
          eval_val reg left;
          let arg = eval_arg (reg+1) right;
          in  emit (Apply(op,reg,arg))
      | ProcCall(proc,args) ->
          if reg > 0 then emit (PushRegs (reg-1));
          List.iter eval_push args;
          emit_call proc;
          if reg > 0 then begin
            emit (MoveTo(Reg 0,reg));
            emit (PopRegs (reg-1))
          end     
      | MethodCall(target,proc,args) ->
          if reg > 0 then emit (PushRegs (reg-1));
          List.iter eval_push args;
          eval_val 0 target; 
          emit_callto proc;
          if reg > 0 then begin
            emit (MoveTo(Reg 0,reg));
            emit (PopRegs (reg-1))
          end
    in
    ...

À l'aide de cette traduction des expressions, nous sommes maintenant prêts à traduire les instructions.

Encore quelques fonctions utilitaires pour gérer les sauts et mettre à jour les étiquettes :

 
Sélectionnez
    let forward () =
      {label=None}
    and emit_label () =
      let l = {label=Some label} in
      emit (Label l);
      label <- label + 1; l
    and assign_label l =
      let here = Some label in
      emit (Label {label=here}); l.label <- here; 
      label <- label + 1
    and branch_to_here from =
      emit (Label {label=Some label});
      from.label <- Some label;
      label <- label + 1
    and emit_break = function
      | Loop(cmd,ended) -> emit (Branch ended)
      | _ -> assert false
    and negate = function
      | Equal    -> NotEqual
      | Less     -> NotLess
      | More     -> NotMore
      | NotEqual -> Equal
      | NotLess  -> Less
      | NotMore  -> More
    in
    ...

Évaluer une condition et placer la cible d'une assignation dans un opérande :

 
Sélectionnez
    let eval_test expr =
      eval_val 0 expr;
      let l = forward () in
      match expr with
      | Compare(cond,left,right) ->
          emit (BranchIf(negate cond,l)); l 
      | _ ->       
          emit (BranchIf(NotEqual,l)); l 
    and eval_dest reg = function
      | Local v -> Stack (offset v)
      | Global v -> Main (offset v)
      | expr -> eval_addr reg expr; Offset(reg,0)
    in
    ...

Et nous pouvons maintenant générer le code pour les instructions :

 
Sélectionnez
    let rec eval_instr = function
      | Nop ->
          ()
      | Block cmds ->
          List.iter eval_instr cmds
      | AssignTo(source,target) ->
          eval_val 0 source;
          let dest = eval_dest 1 target in
          emit (MoveFrom(dest,0))
      | IfThenElse(cond,cmd_t,Nop) ->
          let test = eval_test cond in 
          eval_instr cmd_t;
          branch_to_here test
      | IfThenElse(cond,cmd_t,cmd_f) ->
          let test = eval_test cond in 
          eval_instr cmd_t;
          let done_t = forward () in
          emit (Branch done_t);
          branch_to_here test;
          eval_instr cmd_f;
          branch_to_here done_t 
      | WhileDo(cond,cmd) ->
          let start = emit_label () in
          let test = eval_test cond in 
          eval_instr cmd;
          emit (Branch start);
          branch_to_here test
      | Loop(cmd,ended) ->
          let start = emit_label () in
          eval_instr cmd;
          emit (Branch start);
          assign_label ended
      | Break loop ->
          emit_break loop
      | Return((l,a,f,b),v) ->
          eval_val 0 v;
          emit (ReturnPop a)
      | Ignore call ->
          eval_val 0 call
    in
    ...

La génération du code pour les procédures ne consiste plus qu'à déclarer leur étiquette, créer le cadre de pile et traduire leur corps d'instructions :

 
Sélectionnez
    let eval_proc (l,a,f,b : ast_proc) =
      assign_label l; emit (Frame f); eval_instr b 
    in  List.iter eval_proc proc_list
 

XI-A-7. 71. Le traducteur en action

Supposons qu'à l'entrée du front-end on ait ce bout de code dans un langage source fictif proche de Pascal/MODULA :

 
Sélectionnez
RECORD binary_tree
  left:  PTR TO binary_tree
  right: PTR TO binary_tree
ENDRECORD

PROC height(t:PTR TO binary_tree)
  LOCAL l:LONG,r:LONG
  IF t = NIL THEN RETURN 0:= height(t.left)
  r := height(t.right)
  IF l > r THEN RETURN l+1
  RETURN r+1
ENDPROC

À l'entrée du back-end on aurait alors cet arbre de syntaxe abstraite :

 
Sélectionnez
let rec height : ast_proc =
  let left = Var 0
  and right = Var 1
  and zero = Integer 0
  and one = Integer 1
  and t = Local (Var 2)
  and l = Local (Var (-1))
  and r = Local (Var (-2))
  in
  {label=None},
  1,
  2,
  Block
  [
  IfThenElse(Compare(Equal,t,zero),Return(height,zero),Nop);
  AssignTo(ProcCall(height,[Member(t,left)]),l);
  AssignTo(ProcCall(height,[Member(t,right)]),r);
  IfThenElse
  (
  Compare(More,l,r),
  Block[Return(height,BinaryOp(Add,l,one))],
  Nop
  );
  Return(height,BinaryOp(Add,r,one))
  ]

Il suffit alors d'invoquer notre traducteur :

 
Sélectionnez
(new ast_to_asm [height])#asm_code;;

Pour obtenir ce code machine :

 
Sélectionnez
[Label {label = Some 0}; Frame 2; MoveTo (Stack 2, 0);
 BranchIf (NotEqual, {label = Some 1}); MoveTo (Imm 0, 0); ReturnPop 1;
 Label {label = Some 1}; MoveTo (Stack 2, 0); MoveTo (Offset (0, 0), 0);
 Push (Reg 0); Call {label = Some 0}; MoveFrom (Stack (-1), 0);
 MoveTo (Stack 2, 0); MoveTo (Offset (0, 1), 0); Push (Reg 0);
 Call {label = Some 0}; MoveFrom (Stack (-2), 0); MoveTo (Stack (-1), 0);
 Apply (Sub, 0, Stack (-2)); BranchIf (NotMore, {label = Some 2});
 MoveTo (Stack (-1), 0); Apply (Add, 0, Imm 1); ReturnPop 1;
 Label {label = Some 2}; MoveTo (Stack (-2), 0); Apply (Add, 0, Imm 1);
 ReturnPop 1]

Qu'on pourra reformater ainsi pour une meilleure visibilité :

 
Sélectionnez
Label 0
     Frame 2
     MoveTo    Stack 2, R0
     BranchIf  NotEqual, 1
     MoveTo    Imm 0, R0
     ReturnPop 1
Label 1
     MoveTo    Stack 2, R0
     MoveTo    [R0], R0
     Push      R0
     Call 0
     MoveFrom  Stack -1, R0
     MoveTo    Stack 2, R0
     MoveTo    [R0 + 1], R0
     Push      R0
     Call 0
     MoveFrom  Stack -2, R0
     MoveTo    Stack -1, R0
     Apply     Sub, R0, Stack -2
     BranchIf  NotMore, 2
     MoveTo    Stack -1, R0
     Apply     Add, R0, Imm 1
     ReturnPop 1
Label 2
     MoveTo    Stack -2, R0
     Apply     Add, R0, Imm 1
     ReturnPop 1

À titre de comparaison voici le même programme écrit cette fois manuellement en assembleur 68020 :

 
Sélectionnez
STRUCTURE Tree,0
        APTR  tree_left
        APTR  tree_right
        LABEL tree_sizeof
        
height_enter:
        MOVE.L   (4,SP),D0
        BEQ.S    height_exit0
        MOVE.L   D0,A0
        MOVE.L   (tree_left,A0),-(SP)
        BSR.S    height_enter
        MOVE.L   (4,SP),A0
        MOVE.L   D0,-(SP)
        MOVE.L   (tree_right,A0),-(SP)
        BSR.S    height_enter
        MOVE.L   (SP)+,D1
        CMP.L    D1,D0
        BGT.S    height_exit1
        EXG.L    D1,D0
height_exit1:
        ADDQ.L   #1,D0
height_exit0:
        RTD      #4

La version manuelle est évidemment meilleure :

  • elle ne procède pas à la création/destruction d'un cadre de pile ;
  • elle n'est pas calquée sur les constructions de la programmation structurée ;
  • elle ne contient que 7 instructions MOVE au lieu de 11, car elle alloue les registres beaucoup plus intelligemment.


Le point positif c'est que, malgré sa simplicité, notre générateur ne fait rien de réellement stupide. En particulier on n'a pas besoin d'une passe supplémentaire dans un pipeline qui éliminerait du code inutile. Si on veut améliorer la qualité du code, on peut toujours ajouter des cas au filtrage. Le faire sur l'arbre de syntaxe abstraite est bien plus confortable que de le faire sur une séquence de code.

Par exemple au lieu de l'instruction :

 
Sélectionnez
     MoveTo    Imm 0, R0

On peut préférer l'instruction :

 
Sélectionnez
     Apply     Sub, R0, R0

Dont l'avantage est de ne pas stocker la constante zéro et donc d'économiser de la place (un mot machine sur CPU Intel).
Pour ça il suffira d'ajouter un cas au filtrage :

 
Sélectionnez
    and eval_val reg = function
      | Integer 0 -> emit (Apply(Sub,reg,(Reg reg)))

Évidemment ce serait encore mieux de pouvoir économiser des instructions complètes et on retombe là encore sur le point noir de notre schéma : il génère beaucoup de MOVE.

 

XI-A-8. Pour aller plus loin

Le code que nous avons présenté est assez efficace quant à minimiser le nombre d'instructions PUSH.
Les optimisations que notre traducteur simpliste n'effectue pas se rangent en trois catégories principales :

  • minimiser le nombre d'instructions BRANCH à l'aide d'une analyse du CFG (Control Flow Graph) ;
  • minimiser le nombre d'instructions MOVE en augmentant la granularité de l'allocation de registre de la taille de l'expression jusqu'à la taille de la fonction, du module ou du programme ;
  • retirer le code mort à l'aide d'une analyse du flot de données. C'est particulièrement important dans le cadre du développement modulaire ou POO où l'on thésaurise du code dont seule une petite fraction participe effectivement au programme client.

Des articles Wikipédia donnent des définitions de base pour ces trois domaines :


Dans sa forme la plus simpliste, l'allocation globale de registre se contente de registériser les quelques variables les plus utilisées dans les boucles. C'est une approche simple et utilisée par de nombreux outils populaires gratuits comme LCC.
Pour faire un peu mieux il faut procéder à une mise sous forme de CFG et utiliser une heuristique d'allocation comme linear scan.
Pour une allocation encore plus efficace cumulée avec le retrait du code mort, il faut passer par une représentation intermédiaire dite SSA (Static Single Assignment Form).

Tout cela ce sont des heuristiques, car le problème de l'allocation optimale de registre est NP-complet et même indécidable en présence de boucles.

Ces quelques ressources devraient vous aider à approfondir ces sujets :


précédentsommairesuivant

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2013 Damien Guichard. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.