Analyse sémantique (un peu)
Code intermédiaire.

Postscript, Luc Maranget Le poly

Les phases « sémantiques »

Elle reposent sur la sémantique du langage et s'effectue sur l'arbre de syntaxe abstraite. Toutes ces phases ont en commun de suivre la sémantique des noms.

Portée lexicale

let x = "coucou" in x ^ (let x = 1 in string_of_int (x+1)) ^ x)
Conséquence à l'évaluation : Ou encore :

Tables d'associations impératives

Comme les tableaux, les indices sont quelconques.

Nous avons besoin :
let eval = function | Var x -> get x | Let (x, ex, e) -> let vx = eval ex in let old_vx = set x vx in let ve = eval e in restore x old_vx ; ve

Réalisation, par les tables de hachage bien sûr

exception Free of string let env = Hashtbl create 17 let get x = try Hashtbl.find env x with Not_found -> raise (Free x) let set x v = let old_v = try Some (get x) with Free _ -> None in Hashtbl.replace env x v ; old_v let restore x old_v = match old_v with | None -> Hashtbl.remove env x (* détruire la liaison de x *) | Some v -> Hashtbl.replace env x v (* restaurer la liaison de x *)

Associations fonctionelles

Le style impératif est peu sûr (on a vite fait d'oublier un restore).

On souhaite adopter le style de la sémantique avec un environnement passé en argument.
let rec eval env = function | Var x -> get env x | Let (x, ex, e) -> let vx = eval env ex in eval (extend x vx env) e | ...
Plutôt que des tables d'associations, on veut des associations tout court.

Réalisation naïve

Avec des listes bien sûr.
(* On pourrait utiliser List.assoc qui fait la même chose *) let rec get x env = match env with | [] -> raise (Free x) | (y,v)::rem -> if x=y then v else get x rem let extend x v env = (x,v)::env
Noter que les liaisons sont cachées et non plus détruites.

get devient innefficace pour les environnements sont un peu grands.

Réalisation efficace

(* Module des chaînes ordonnées *) module OrderedString = struct type t = string let compare s1 s2 = Pervasive.compare s1 s2 (* ordre standard *) end (* Application du foncteur => associations aux chaînes *) module StringMap = Map.Make OrderedString let get x env = try StringMap.find x env with Not_found -> raise (Free x) let extend x v env = StringMap.add x v env

Catégories (ou espaces) de noms

Diverses entitées sont nommées (presque) indépendamment. À l'usage, c'est le contexte (syntaxique) qui détermine les espaces de noms :
f() ¬ ∼f := ...


Réalisation par des enregistrements, une association par catégorie.

Interface des environnements en Pseudo-Pascal

type ('a, 'b) environment exception VarNotFound of string val find_var : ('a,'b) environment -> string -> 'a val find_definition : ('a,'b) environment -> string -> 'b val create_global : (string * 'a) list -> (string * 'b) list -> ('a,'b) environment val add_local_vars : ('a,'b) environment -> (string * 'a) list -> ('a,'b) environment val change_local_vars : ('a,'b) environment -> (string * 'a) list -> ('a,'b) environment


Environnements lors de l'exécution

Les noms des variables n'existent plus. Les variables sont des cases : Dans le cas traditionnel : L'allocation en pile provient de la récursivité : les variables (locales) appartiennent aux appels.

Chaque appel possède une tranche (un frame) de pile. Il accède aux variables par le fond (fp), et dispose du sommet à sa guise (sp).

Organisation traditionelle de la pile



Séquence d'appel traditionelle

  1. L'appelant empile les paramètres effectifs (et une place pour ranger la valeur de retour).
  2. L'appelant exécute une instruction d'appel de sous-routine.
  3. L'appelé empile le registre fp, puis copie sp dans fp.
  4. L'appelé alloue l'espace nécessaire aux variables locales.
  5. L'appelé s'exécute. il doit rendre sp comme en 4.
  6. L'appelé rend l'espace de pile des variables locales.
  7. Il dépile dans fp, qui retrouve sa valeur de l'étape 1.
  8. Instruction de retour (pop and branch).
  9. L'appelant dépile les arguments par lui empilés au début.

Méthode moderne

Il y a bien un frame par appel. Mais...
Conséquence pour la compilation
On ne choisit pas tout de suite si les variables locales vont en registre ou en pile.

Pour le moment, les variables locales sont des « temporaires ». C'est un premier exemple d'une construction de code dit « intermédiaire ».

Pourquoi un code intermédiaire?


Partage du travail

Le principe du code intermédiaire

Le code intermédiaire est celui d'une machine idéale.

Les détails dépendant de l'architecture sont relégués à une phase ultérieure de sélection d'instructions.

Quelques caractéristiques du code intermédiaire:

Notre code intermédiaire


Expressions (en arbre)
type exp = Const of int (* Entiers et Booléens *) | Name of Gen.label (* Adresse mémoire nommée *) | Temp of Gen.temp (* Lecture d'un temporaire *) | Mem of exp (* Lecture mémoire *) | Bin of binop * exp * exp (* Opération binaire *) | Call of Frame.frame * exp list (* Appel de fonction ou appel système *)
Note  Données dans le code intermédiare
Instructions (à plat ou presque)
and stm = | Label of Gen.label (* Étiquette (dans le code) *) | Move_temp of Gen.temp * exp (* Écriture dans un temporaire *) | Move_mem of exp * exp (* Écriture en mémoire *) | Seq of stm list (* Séquence d'instructions *) | Exp of exp (* Expression évaluée pour son effet *) | Jump of Gen.label (* Saut non conditionnel *) | Cjump of (* Saut conditionnel *) relop * exp * exp * (* - Comparaison *) Gen.label * Gen.label (* - si vrai/si faux *)
Noter les modules de « service » (Gen et Frame).

Compiler vers le code intermédiaire


Une phase en trois passes




Principe :
Avantages : Robustesses, modularité, généralité.

Les temporaires


« Sémantique »
Interface (module Gen)
type temp val new_temp : unit -> Gen.temp


Génération de code, cas simples

Fonctions [[ _ ]]ρe (expressions) et [[ _ ]]ρs (instructions).

Conditionnelle

Test direct (<, ≤, etc.)
[[If  (Bin (relop, e1, e2), st, sf)]]ρs =
Seq 



Cjump (relop, [[e1]]ρe, [[e2]]ρe, lt, lf);
Label lt; [[st]]ρs; Jump fi;
Label lf; [[sf]]ρs; Jump fi;
Label fi;




Sinon, à la C :
[[If (e1, st, sf)]]ρs = [[If  (Bin (Ne , e1, Const 0), st, sf)]]ρs
Noter les étiquettes sont « fraîches » (obtenues par Gen.new_label).

La boucle while est similaire.
[[While  (Bin (relop, e1, e2), sl)]]ρs =
     Seq 



Label test;
Cjump (relop, [[e1]]ρe, [[e2]]ρe, loop, fi);
Label loop; [[sl]]ρs; Jump test;
Label fi;






Les variables

Les environnements associent les noms des variables à, ...

Fabriquer les environnents

Dans le cas simple de Pseudo-Pascal.

En pratique

Le type générique des environnements (module Env) est :
type ('a, 'b) environment


Les environnements utilisés pour compiler Pseudo-Pascal :
(case, Frame.frame) environment
case est du style :
type case = | Tempo of Gen.temp (* La variable est un temporaire *) | Memoire of Code.expr (* La variable est en mémoire *)


Introduction des temporaires

Les frames (représentation des fonctions) contiennent la liste des temporaires représentant leurs paramètres (et leur résultat éventuel).

Les frames (enfin)

C'est un point de rendez-vous dans tout le back-end.
type frame = { name : Gen.label; (* Point d'entrée (prologue) *) return_label : Gen.label; (* Adresse de l'épilogue *) args : Gen.temp list; (* Temporaire des arguments *) result : Gen.temp option; (* Temporaire du résultat (ou rien) *) mutable mysize : int; (* Taille nécessaire sur la pile *) }


Mais le type des frames est abstrait, dans frame.mli, on aura la déclaration : type frame. En effet
Interface (fichier frame.mli)
type frame (* Création *) val named_frame : string -> Pp.var_list -> Pp.type_expr option -> frame (* Accéder aux informations *) val frame_name : frame -> label val frame_args : frame -> temp list val frame_result : frame -> temp option val frame_return : frame -> label


Compilation des programmes


Compiler une fonction
En deux étapes.
val cfun : (access, Frame.frame) Env.environment -> string * Pp.definition -> Frame.frame * Code.stm



Compiler un programme
En quatre étapes :
type 'a procedure = Frame.frame * 'a type 'a program = { number_of_globals : int; main : 'a procedure; procedures : 'a procedure list } val program : Pp.program -> Code.stm program


Code intermédiaire, deuxième partie

Notre code n'est pas prêt

En première approximation, notre code est prêt pour la sélection si sa traduction en code assembleur est simple et indépendante de l'ordre d'évaluation des expressions.

Pouvoir de la sélection

Il est souhaitable que les expressions soient des arbres.
Move (t0, Bin (Plus , e1, e2))
Est selectionnable de au moins deux façons :
Code qui calcule e1 dans r      Code qui calcule e2 dans r
Code qui calcule e2 dans r'      Code qui calcule e1 dans r'
t0r+r'
À quoi bon ?   Par exemple, bonne utilisation des registres.
N(e1 + e2) =

max(N(e1), N(e2))      Si N(e1) ≠ N(e2)
N(e1) + 1      Sinon
À condition de mettre en premier l'argument de N(ei) maximal.

Sélection

Bin (Plus , Bin (Plus , Temp t1, Const 2), Bin (Plus , Temp t2, Const 1))
Peut donner lieu à deux selections
add t3, t1, 2 add t4, t2, 1 add t5, t3, t4
    
add t3, t2, 1 add t4, t1, 2 add t5, t3, t4

On peut laisser la sélection décider : il n'y aucune implication sémantique.

Sélection, autre exemple

Bin (Plus , Call (f,[e1]), Call (g, [e2]))


On ne peut pas laisser la sélection décider : les deux choix ont des sémantiques différentes (effets de bords dans les fonctions).

Comment s'en tirer :

Plus grave, la compilation des appels imbriqués

Call (f, Call (g, e1), Call (h, e2))
Ici c'est grave, la selection la plus naïve est incorrecte.
a0e1   #   argument de g
call g   #   le résultat de g...
a0v0   #   est le premier argument de f.
a0e2   #   argument de h
  …   #  


Notons qu'il est possible de s'en tirer dans la selection.
a0e1   #   argument de g
call g   #   le résultat de g...
t0v0   #   « sauver » le premier argument de f.
a0e2   #   argument de h
call h   #  
a0t0   #   premier argument de f
a1v0   #   second argument de f
call f   #  
Une fois de plus, c'est compliqué (et dupliqué dans chaque sélecteur).

Une solution définitive et modulaire

Avant la sélection, le code est « canonisé ».

Le code canonique est un code intermédiaire contraint :

Un example de canonisation

Seq  [Move (t0, Bin (Plus , Call (f,e1), Call (g, (Call (h, e2))))) ; …]
Se canonise en :
Move (t1, e1)
Move (t2, Call (f, Temp t1))
Move (t3, e2)
Move (t4, Call (h, Temp t3))
Move (t5, Call (g, Temp t4))
Move (t0, Bin (Plus , Temp t1, Temp t5))
  …

Décrire la canonisation : l'arme absolue

Règle (de réécriture) e sc. Une expression e se réduit un code  s et une expression résiduelle c canoniques.
Const _ Const _     Temp _ Temp _     Name _ Name _
e sc
Mem e sMem  c
e1 s1c1           e2 s2c2
Bin (op, e1, e2) s1 ; Move (t, c1) ; s2Bin (op, Temp t, c2)
e1 s1c1           ⋯           en sncn
Call (f,[e1 ; … ; en]) s1 ; Move (t1, c1) ; … ; sn ; Move (tn, cn) ;Move (tn+1, Call (f,[Temp t1 ; … ; Temp tn]))Temp tn+1

Arme absolue II

L'instruction s se canonise en la liste d'instructions s' (s s').
s1 s'1           ⋯    sn s'n
Seq [s1 ; … sn] s'1 ; ⋯ ; s'n
    
e sc
Move (t, e) s ; Move (t, c)
e1 s1c1           e2 s2c2
Move_mem (e1, e2) s1 ; Move (t,c1) ; s2 ; Move_mem (t, c2)
e1 s1c1           e2 s2c2
Cjump (relop, e1, e2, lT, lF) s1 ; Move (t,c1) ; s2 ; Cjump (relop, t, c2, lT, lF)


On peut éviter quelques transferts:
e sc
Move (t, Call (f, e)) s ; Move (t, Call (f, c))
Bref, on met à plat les Seq et on canonise les expressions.

Une arme trop puissante

Move (t0, Bin (Plus , Bin (Mult , Temp t1, Const 4), Const 7))
Se canonise en :
Move (t', Bin (Mult , Temp t1, Const 4))
Move (t0, Bin (Plus , Temp t', Const 7))
Et c'est bien domage car certains processeurs peuvent selectionner une seule instruction.
leal 7(,t1,4), t0


Donc, on cherche à garder des expressions en arbre le plus possible.

Commutation

Informellement, deux expressions commutent si on peut les évaluer n'importe comment sans mettre la sémantique en danger.

Alors la règle suivante semble correcte.
e1 s1c1           e2 s2c2          
e1 et e2 commutent
Bin (op, e1, e2) s1 ; s2Bin (op, c1, c2)


Si on réfléchit à la correction :
e1 s1c1           e2 s2c2          
c1 et s2 commutent
Bin (op, e1, e2) s1 ; s2Bin (op, c1, c2)


Exemples de commutation

On se pose donc la question de savoir si évaluer c1 avant ou après s2 est indifférent. Comment généraliser ?
On commute si c1 ne lit pas « ce qui » est écrit par s2 :

Autres occurences de la commutation

Par exemple, pour l'écriture en mémoire :
e1 s1c1           e2 s2c2          
c1 et s2 commutent
Move_mem (e1, e2) s1 ; s2 ; Move_mem (c1, c2)


Et surtout pour les arguments des fonctions :
[ ] [ ]
e1 s1c1           [e2 ; ⋯ ; en] s[c2 ; ⋯ ; cn]
[e1 ; e2 ; ⋯ ; en] s1 ; Move (t1, c1) ; s[t1 ; c2 ; ⋯ cn]
e1 s1c1           [e2 ; ⋯ ; en] s[c2 ; ⋯ ; cn]          
c1 et s commutent
[e1 ; e2 ; ⋯ ; en] s1 ; s[c1 ; c2 ; ⋯ cn]


Se débarasser du Cjump bi-étiquette

Facile, à l'aide d'une fenêtre se déplaçant sur le code. (Procédé « trou de serrure » ou peephole).
Jump l ; Label l Label l
Cjump (relop, e1, e2, lt, lf) ; Label lf Cjump (relop, e1, e2, lt, lf) ; Label lf
Cjump (relop, e1, e2, lt, lf) ; Label lt Cjump (¬ relop, e1, e2, lf, lt) ; Label lt
Cjump (relop, e1, e2, lt, lf) Cjump (relop, e1, e2, lt, l) ; Label l; Jump lf


Bien, mais on voudrait aussi, quand c'est possible :
Jump l ; Label l
C'est possible quand l n'apparaît pas dans le code.

Optimisation du contrôle

Le code à plat s'y prête très mal. (Même si le Cjmp bi-étiquette explicite la totalité du contrôle).

On transforme le code en « graphe de flot ».

Structures de données pour le graphe flot

Les sommets du graphe : des blocs de base.
type basic_block = {enter:Gen.label ; (* Étiquette d'entrée *) mutable succ:stm ; (* Dernière instruction (un saut) *) body:stm list ;} (* Instructions du bloc *)
La liste des blocs suffit pour représenter le graphe de flot.

Par souci d'efficacité, on ajoute une association étiquette → bloc.
type graph = basic_block list * (Gen.label, basic_block) Hashtbl.t let get_block (_,t) l = Hashtbl.find t l


Exemple de graphe de flot

Le code :
Label test; Cjump (relop, [[e1]]ρe, [[e2]]ρe, loop, fi);
Label loop; … ; Jump test;
Label fi;
Le graphe :

Application du graphe de flot

On compile l'instruction :
If  (Bin (op1, e1, e2), st, If (op2, e3, e4, sft, sff))
Le code :
Label start ; Cjump (op1, _, _, lt, lf);
Label lt; … ; Jump fi1;
Label lf;
     Cjump (op2, _, _, lft, lff) ;
     Label lft; …  ; Jump fi2
     Label lff; …  ; Jump fi2
     Label fi2
Jump fi1;
Label fi1;


Deux graphes équivalents

            


Optimisation du contrôle


Principe
  1. Transformer le code (d'une fonction) en graphe de flot.
  2. Transformer le graphe.
  3. Transformer le graphe en code (et appliquer le trou de serrure).

Deux optimisation simples

Court-circuit, en pratique


Court-circuiter une étiquette

let rec shorten_lab (_,t) lab = try let b = Hashtb.find t lab in match b with | {body=[]; succ=Jump olab} -> shorten_lab t olab | _ -> lab with | Not_found -> lab
Ne fonctionne pas toujours !

Boucle en cas de bloc vide bouclant sur lui-même.


Traiter un (les) bloc(s)
let shorten_block t b = match b.succ with | Jump lab -> b.succ <- Jump (shorten_lab t lab) | Cjump (op,e1,e2,lab1,lab2) -> b.succ <- Cjump (op, e1, e2, shorten_lab t lab1, shorten_lab t lab2) | _ -> assert false let shorten_blocks (blocks,t) = List.iter (shorten_block t) blocks, t


Pour identifier le code mort :

Un bête parcours de graphe (puis un filtrage de la liste de blocs).

Encore plus fort

Modifier l'ordre de présentation des blocs, pour optimiser.

Sur le graphe de la boucle :

Code initial :
Label test; Cjump (relop, _, _, loop, fi);
Label loop; … ; Jump test;
Label fi;
Code final :
Jump test; Label loop; … ; Jump test;
Label test; Cjump (relop, _, _, loop, fi);
Label fi;
Est-ce bien utile ? Hum.

De plus en plus fort

Dans un graphe arbitraire, retrouver une structure de boucle imbriquée cachée (goto...).
Assez dur en pratique. Dommage.

Organisation de nos optimisations

On ne touchera pas à l'ordre des blocs (ne pas détruire la structure du code, déjà convenable, en voulant faire mieux).
  1. Couper en blocs de base.
  2. Produire le graphe.
  3. Court-cicuiter les blocs vides.
  4. Éliminer les blocs de code mort.
  5. Fusionner le code des blocs mis bout-à bout.
Optimisations « trou de serrure » (peephole) et mise en place des test-and-branch style machine à la fin.
Jump l ; Label l Label l
Cjump (relop, e1, e2, lt, lf) ; Label lf Cjump (relop, e1, e2, lt, lf) ; Label lf
Cjump (relop, e1, e2, lt, lf) ; Label lt Cjump (¬ relop, e1, e2, lf, lt) ; Label lt
Cjump (relop, e1, e2, lt, lf) Cjump (relop, e1, e2, lt, l) ; Label l; Jump lf


Les projets

Voir la page du cours. Modifications de notre compilateur zyva.
  1. Recibler pour le Pentium.
  2. Produire un système à bytecode.
  3. Ajouter le passage par variable puis les fonctions locales.
  4. votre projet

Ce document a été traduit de LATEX par HEVEA