Analyse sémantique (un peu)
Code intermédiaire.
Les phases « sémantiques »
Elle reposent sur la sémantique du langage et s'effectue sur l'arbre
de syntaxe abstraite.
-
Une seule phase réellement indispensable : la génération de code.
- En pratique, bien d'autres phases :
-
Vérification (synthèse) des types.
- Emploi des noms (pas de doublon des fonctions en Pseudo-Pascal),
incluses dans le typage le plus souvent.
- Optimisations de « haut-niveau ».
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 :
-
créer une liason entre x et "coucou",
pour évaluer
x ^
,
- créer une liaison entre x et 1
pour évaluer
string_of_int (x+1)
,
- retrouver la première liaison pour évaluer
^ x
.
Ou encore :
-
string_of_int (x+1)
est évalué dans un environnement où
x vaut 1.
x ^
et ^ x
sont évalués dans un environnement où
x vaut "coucou".
Tables d'associations impératives
Comme les tableaux, les indices sont quelconques.
Nous avons besoin :
-
get : string -> 'a
. Pour accéder aux valeurs des
variables.
set : string -> 'a -> 'a option
. Pour mettre à jour
la valeur d'une variable (renvoie l'ancienne valeur).
restore : string -> 'a option -> unit
. Pour remettre
une ancienne valeur.
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.
-
Pseudo-Pascal : fonctions et variables.
- Java : classes, méthodes, variables.
- C : fonctions, variables, étiquettes d'enregistrements etc.
(toutefois, interdiction de mélanger fonctions et variables de même nom).
À 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 :
-
Des cases mémoire.
- Des registres machine.
Dans le cas traditionnel :
-
Une fonction : l'adresse de son code (étiquette quand même).
- Une variables globale : une case dans le segment statique de
données.
- Une variable locale : une case dans la pile.
- Un paramètre : une case dans la pile.
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
-
L'appelant empile les paramètres effectifs
(et une place pour ranger la valeur de retour).
- L'appelant exécute une instruction d'appel de sous-routine.
- L'appelé empile le registre fp, puis copie sp dans fp.
- L'appelé alloue l'espace nécessaire aux variables locales.
- L'appelé s'exécute. il doit rendre sp comme en 4.
- L'appelé rend l'espace de pile des variables locales.
-
Il dépile dans fp, qui retrouve sa valeur de l'étape 1.
- Instruction de retour (pop and branch).
- 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...-
On peut se passer de fp, si
-
Pas d'allocation arbitraire en pile (alloca).
- Compilation un peu plus complexe acceptée.
- Pas d'interaction avec un debugger.
- Surtout : les paramètres et variables locales peuvent être en
registres (fixés au passage d'arguments pour les premiers).
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
-
Entre plusieurs langages, et plusieurs machines
|
|
|
|
⎫
⎪
⎬
⎪
⎭ |
|
|
|
⎧
⎪
⎨
⎪
⎩ |
MIPS |
xx86 |
... |
Sparc |
Byte-code |
|
|
- Entre les différentes constructions d'une même langage ou d'une même machine
-
La syntaxe abstraite comporte trop de constructions voisines
- Le code assembleur comporte trop d'instructions voisines
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:
-
Les branchements sont explicites.
- Code arborescent (expressions) ou linéaire (instructions)
- Utilise une infinité de registres (temporaires),
dont l'utilisation est privilégiée (réversible) et le coût négligé.
- L'adressage en mémoire est une forme séparée qui n'est retenue que lorsque
c'est indispensable (irréversible).
- L'appel de fonction reste dans le flou, et sera résolu dans une phase ultérieure.
(il depend lourdement du processeur ciblé)
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
-
Les temporaires
Gen.temp
- Les étiquettes
Gen.label
- Les fonctions
Frame.frame
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 :
-
La première passe (simple) introduit des
innefficacités, ou fait le travail à moitié.
- Les suivantes (générales) corrigent.
Avantages : Robustesses, modularité, généralité.
Les temporaires
« Sémantique »
-
Les temporaires ne sont pas tout à fait des registres, car...
- Les appels de fonction ne modifient pas leur contenu.
- Comme :
les variables locales des fonctions récursives.
- Il revient aux phases suivantes de réaliser leur sémantique,
avec les moyens de la vraie machine.
Interface (module Gen)
type temp
val new_temp : unit -> Gen.temp |
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 environnements associent les noms des variables à, ...-
un temporaire t,
|
[[Get x]]ρe |
= |
Temp t |
[[Set (x, e)]]ρs |
= |
Move_temp (t, [[e]]ρe) |
|
- une adresse mémoire a,
|
[[Get x]]ρe |
= |
Mem a |
[[Set (x, e)]]ρs |
= |
Move_mem (a, [[e]]ρe) |
|
- un frame F (représentation ad-hoc des fonctions)
|
[[Procedure_call (f, [ e1 ; … ; en ])]]ρs |
= |
Exp (Call (F, [ [[e1]]ρe ; … ;
[[en]]ρe ])) |
[[Function_call (f, [ e1 ; … ; en ])]]ρe |
= |
Call (F, [ [[e1]]ρe ; … ; [[en]]ρe ]) |
|
Fabriquer les environnents
Dans le cas simple de Pseudo-Pascal.
-
La i-ième variable globale est rangée en mémoire, d'adresse :
Plus (Const (w*(i−1)), Name lglobals)
Autrement dit les variables globales sont en mémoire à la
queue-leu-leu.
L'adresse de cette zone est une étiquette définie dans le module
Frame.
On peut aussi décider qu'un registre Frame.global_register
est réservé pour contenir cette adresse.
Plus (Const (w*(i−1)), Temp rglobals)
- Les autres variables (variables locales, arguments) sont des
temporaires.
- Les noms de fonction sont associés à une structure ad-hoc, nommée frames.
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 |
Où 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
-
Un temporaire « frais » pour chaque variable locale.
(ie. un temporaire obtenu par un appel à
Gen.new_temp
).
- Un temporaire par paramètre formel (et pour le résultat).
-
Pour ne pas dépendre des conventions d'appel à ce niveau.
- Pour donner une chance d'aller en registre.
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
-
Le type
frame
peut changer (machine ciblée, langage compilé).
- Il est compliqué.
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 |
Compiler une fonction
En deux étapes.
-
Créer le frame.
- Compiler le corps :
-
Fabriquer le bon environnement.
- Compiler le corps (une liste d'instructions) dans cet environnement.
val cfun :
(access, Frame.frame) Env.environment ->
string * Pp.definition -> Frame.frame * Code.stm |
Compiler un programme
En quatre étapes :
-
Fabriquer tous les frames (à cause des appels potentiellement récursifs).
- Fabriquer un environnement initial.
- Ajouter une fonction « principale ».
- Compiler toutes les fonctions.
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 encore prêt pour la phase suivante (la
séléction).
- On peut optimiser le contrôle (e.g éviter des sauts inutiles).
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.
-
On veut une liste d'instructions (supprimer les
Seq ).
- On veut des sauts conditionnels raisonables.
Cjump (relop, e1,
e2, lT, lF) ; Label lF
- Mais surtout, les expressions sont des arbres trop génériques.
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' |
|
⇓ |
t0 ← r+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 :
-
Ne pas (trop) spécifier l'ordre d'évaluation : une idée discutable.
-
Le résultat des programmes mal-écrits n'est pas intuitif.
- Des programes optimisés/non optimisés peuvent donner des
résultats différents.
- C'est pas moderne.
- Tenir compte de l'ordre d'évaluation dans la sélection :
une mauvaise idée.
-
C'est compliqué.
- La sélection dépendrait alors du langage compilé !
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.
a0 ← e1 |
# |
argument de g |
call g |
# |
le résultat de g... |
a0 ← v0 |
# |
est le premier argument de f. |
a0 ← e2 |
# |
argument de h |
… |
# |
Notons qu'il est possible de s'en tirer dans la selection.
a0 ← e1 |
# |
argument de g |
call g |
# |
le résultat de g... |
t0 ← v0 |
# |
« sauver » le premier argument de f. |
a0 ← e2 |
# |
argument de h |
call h |
# |
a0 ← t0 |
# |
premier argument de f |
a1 ← v0 |
# |
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 → s ⊕ c.
Une expression e se réduit
un code s et une expression résiduelle c
canoniques.
Const _ → ⊕ Const _
Temp _ → ⊕ Temp _
Name _ → ⊕ Name _ |
|
e → s ⊕ c |
|
Mem e → s ⊕ Mem
c |
|
|
|
e1 → s1 ⊕ c1
e2 → s2 ⊕ c2 |
|
Bin (op, e1, e2) →
s1 ; Move (t, c1) ; s2 ⊕
Bin (op, Temp t, c2) |
|
|
|
e1 → s1 ⊕ c1
⋯
en → sn ⊕ cn |
|
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 → s ⊕ c |
|
Move (t, e)
→
s ; Move (t, c) |
|
|
|
e1 → s1 ⊕ c1
e2 → s2 ⊕ c2 |
|
Move_mem (e1, e2)
→
s1 ; Move (t,c1) ;
s2 ; Move_mem (t, c2) |
|
|
|
e1 → s1 ⊕ c1
e2 → s2 ⊕ c2 |
|
Cjump (relop, e1, e2, lT, lF)
→
s1 ; Move (t,c1) ;
s2 ; Cjump (relop, t, c2, lT, lF) |
|
|
On peut éviter quelques transferts:
e → s ⊕ c |
|
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.
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 → s1 ⊕ c1
e2 → s2 ⊕ c2
|
|
|
|
Bin (op, e1, e2) →
s1 ; s2 ⊕
Bin (op, c1, c2) |
Si on réfléchit à la correction :
-
c1 et c2 commutent toujours (voir les règles).
- On veut seulement évaluer c1 après s2, la
sémantique commandant le contraire.
e1 → s1 ⊕ c1
e2 → s2 ⊕ c2
|
|
|
|
Bin (op, e1, e2) →
s1 ; s2 ⊕
Bin (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.
-
Si c1 est une constante ?
s2 quelconque.
- Si c1 est un temporaire t ?
s2 n'écrit pas dans t.
- Si c1 est Bin (op, e1, e2) ?
s2 commute avec e1 et e2.
- Si c1 est un appel de fonction ?
C'est un piège, c1 est canonique.
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 → s1 ⊕ c1
e2 → s2 ⊕ c2
|
|
|
|
Move_mem (e1, e2)
→
s1 ; s2 ; Move_mem (c1, c2) |
|
|
Et surtout pour les arguments des fonctions :
|
|
e1 → s1 ⊕ c1
[e2 ; ⋯ ; en] → s ⊕ [c2 ; ⋯
; cn] |
|
[e1 ; e2 ; ⋯ ; en] →
s1 ; Move (t1, c1) ; s ⊕ [t1
; c2 ; ⋯ cn] |
|
|
|
e1 → s1 ⊕ c1
[e2 ; ⋯ ; en] → s ⊕ [c2 ; ⋯
; cn]
|
|
|
|
[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 :
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
-
Transformer le code (d'une fonction) en graphe de flot.
- Transformer le graphe.
- Transformer le graphe en code (et appliquer le trou de serrure).
Deux optimisation simples
-
Court-circuiter les blocs vides.
Éviter les sauts vers les sauts.
- Identifier le code mort.
Supprimer du code inutile (cosmétique).
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).
-
Couper en blocs de base.
- Produire le graphe.
- Court-cicuiter les blocs vides.
- Éliminer les blocs de code mort.
- 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.
-
Recibler pour le Pentium.
- Produire un système à bytecode.
- Ajouter le passage par variable puis les fonctions locales.
-
Ce document a été traduit de LATEX par HEVEA