Compilation et machines abstraites - Gallium

Transcription

Compilation et machines abstraites - Gallium
Compilation et machines abstraites
Xavier Leroy
INRIA Rocquencourt
1
Introduction
Buts de ce cours:
• Présenter les principales techniques d’implémentation des
langages de programmation, notamment des langages
fonctionnels
(machines abstraites, optimisation et génération de code
machine).
• Justifier formellement la correction sémantique de ces
techniques.
2
Plan du cours
1. Langages fonctionnels, fermetures de fonctions et
environnements.
2. Machines abstraites pour les langages fonctionnels.
3. Extension à la réduction forte.
4. Introduction à la compilation optimisante.
5. Optimisations à base d’analyses dataflow.
3
Première partie
Langages fonctionnels et fermetures de fonctions
4
Qu’est-ce qu’un langage fonctionnel?
• Exemples: Caml, Haskell, Scheme, SML, . . .
• “Un langage qui prend les fonctions au sérieux”.
• Un langage qui permet de manipuler les fonctions comme
des valeurs de première classe, suivant le modèle du λ-calcul.
Le λ-calcul en deux lignes:
Termes: a, b ::= x | λx.a | a b
Règle de calcul: (λx.a) b → a{x ← b}.
5
Langages fonctionnels = λ-calcul appliqué
La recette du langage fonctionnel:
• Choisir une stratégie d’évaluation pour le λ-calcul.
– Évaluation faible (pas de réduction sous les λ).
– Appel par nom ou par valeur.
• Ajouter des constantes et des opérateurs primitifs
– Entiers et opérateurs arithmétiques.
– Flottants, booléens, chaı̂nes, entrées/sorties, . . .
• Ajouter des structure de données
– Prédéfinies: n-uplets, listes, tableaux, . . .
– Définies par le programmeur: enregistrements, sommes,
types récursifs.
6
Les fonctions comme valeurs:
le problème des variables libres
let scale = λn. λx. n * x
let scale_by_2 = scale 2
let scale_by_10 = scale 10
scale 2 est supposée renvoyer en résultat une fonction qui se
comporte comme λx. x * 2. C’est ce que prédit la règle de
β-réduction:
(λx.a) b → a{x ← b}
7
La substitution textuelle est problématique
(λx.a) b → a{x ← b}
L’implémentation naturelle de la substitution implique une copie
complète du terme a, en remplaçant les occurrences de x par b.
(Si b contient des variables libres, une copie de b peut aussi être nécessaire
pour éviter les captures de variables. Cependant, lorsqu’on réduit faiblement
des termes clos, b est nécessairement clos.)
a{x ← b}
a
x
a
x
b
b
8
Le problème s’aggrave dans un cadre compilé
Dans le cadre de la compilation, la représentation standard
d’une valeur de fonction est un pointeur vers un morceau de
code machine compilé qui:
•
•
•
•
attend son argument x dans un registre arg;
calcule le corps de la fonction a;
dépose la valeur de a dans un registre res;
retourne vers l’appelant.
Si nous appliquons naı̈vement ce modèle “fonction = pointeur
de code” à un langage fonctionnel, les fonctions qui renvoient
des fonctions en résultat devraient générer dynamiquement un
morceau de code compilé représentant la fonction résultat.
9
Exemple:
let scale = λn. λx. n * x
scale 2 renvoie
mov res, arg
mul res, 2
return
scale 10 renvoie
mov res, arg
mul res, 10
return
Problème: ceci nécessite de la génération dynamique de code,
qui est complexe et coûteuse (en temps et en taille de code).
10
Vers une meilleure solution
Remarque: les blocs de code ainsi générés ont la même
“forme”: ils diffèrent seulement sur la valeur de la variable n qui
est libre dans la fonction résultat λx. x * n.
mov res, arg
mul res, <la valeur de n passée à scale>
return
Idée: partager la partie commune du code et mettre les parties
variables (c.à.d. les valeurs des variables libres) dans une
structure de donnée séparée qu’on appelle un environment.
La même idée s’applique aux substitutions sur les termes: pour
représenter le terme a{x ← b}, on garde a inchangé et on
enregistre séparément la liaison de x à b dans un environnement.
11
Les fermetures de fonctions (P. J. Landin, 1964)
Toutes les valeurs fonctionnelles sont représentées par des
fermetures de fonctions (function closures).
Les fermetures sont des structures de données allouées
dynamiquement, contenant:
• un pointeur de code, qui pointe vers un morceau de code
fixé, calculant le résultat de la fonction;
• un environnement: un enregistrement associant des valeurs
aux variables libres de la fonction.
Pour appliquer une fermeture, on place la partie environnement
dans un registre conventionnel env, et on appelle le code désigné
par le pointeur de code.
12
valeur de retour de scale 10
•
10
(bloc alloué dynamiquement)
mov res, arg
load n, env[1]
mul res, n
return
(bloc de code fixe)
13
Les fermetures dans un cadre interprété
Les fermetures peuvent aussi être vues comme des termes
(λx.a)[e]
où λx.a est une fonction ayant y, z, . . . comme variables libres,
et l’environnement e est une substitution explicite qui lie des
termes b, c, . . . à y, z, . . .
Cette vision est essentielle pour obtenir des inteprètes efficaces
pour les langages fonctionnels. Nous allons le voir via le chemin
suivant:
• Sémantique standard: petits pas (à réductions),
substitutions classiques.
• Sémantique à grands pas avec substitutions classiques.
• Sémantique à grands pas avec environnements.
• Sémantique à petits pas avec environnements / substitutions
explicites.
14
Sémantique à petits pas et substitution classique
Termes:
Constantes:
Opérateurs:
Valeurs:
a ::= x | λx.a | a1 a2 | cst | op(a1, . . . , an)
cst ::= 0 | 1 | . . .
op ::= + | pair | fst | snd | . . .
v ::= λx.a | cst
Règles de réduction:
(λx.a) v → a{x ← v}
(βv )
op(v1, . . . , vn) → v if v = op(v1, . . . , vn) (δ)
Stratégie: réduction faible, appel par valeur, évaluation de
gauche à droite.
a → a0
b → b0
a b → a0 b
v b → v b0
a → a0
op(v1, . . . , vk−1, a, bk+1, . . . , bn) → op(v1, . . . , vk−1, a0, bk+1, . . . , bn)
15
Sémantique à grands pas et substitution classique
Au lieu d’enchaı̂ner des réductions élémentaires a → a1 → . . . → v,
on définit une relation d’évaluation “à grands pas” a ⇒ v qui
saute directement de a à sa valeur v. (a doit être clos.)
(λx.a) ⇒ (λx.a)
a ⇒ (λx.c)
b⇒v
cst ⇒ cst
c{x ← a} ⇒ v 0
a b ⇒ v0
a1 ⇒ v1
...
an ⇒ vn
v = op(v1, . . . , vn)
op(a1, . . . , an) ⇒ v
∗
Théorème: si a est clos, a → v si et seulement si a ⇒ v.
16
Sémantique à grands pas et environnements
On représente les valeurs fonctionnelles par des fermetures
notées (λx.a)[e].
Termes:
Valeurs:
Environnements:
a ::= x | λx.a | a1 a2 | cst | op(a1, . . . , an)
v ::= (λx.a)[e] | cst
e ::= {x1 ← v1, . . . , xn ← vn}
La relation d’évaluation devient e ` a ⇒ v.
a peut maintenant contenir des variables libres, mais celles-ci
doivent être liées dans l’environnement e.
e ` x ⇒ e(x)
e ` (λx.a) ⇒ (λx.a)[e]
e ` a ⇒ (λx.c)[e0]
e`b⇒v
e ` cst ⇒ cst
e0 + {x ← v} ` c ⇒ v 0
e ` a b ⇒ v0
e ` a1 ⇒ v1
...
e ` an ⇒ vn
v = op(v1, . . . , vn)
e ` op(a1, . . . , an) ⇒ v
17
Reformulation avec des indices de De Bruijn
La notation de De Bruijn notation: au lieu d’identifier les
variables par leur nom, on les identifie par leur position.
λx. (λy. y x) x
| | |
λ
(λ
1 2) 1
Les environnements deviennent des suites de valeurs v1 . . . vn.ε,
accédées par position: la variable d’indice n est liée à vn.
18
Reformulation avec des indices de De Bruijn
Termes:
a ::= n | λa | a1 a2 | cst | op(a1, . . . , an)
Valeurs:
v ::= (λa)[e] | cst
Environnements:
e ::= ε | v.e
e ` n ⇒ e(n)
e ` (λa) ⇒ (λa)[e]
e ` a ⇒ (λc)[e0]
e`b⇒v
e ` cst ⇒ cst
v.e0 ` c ⇒ v 0
e ` a b ⇒ v0
e ` a1 ⇒ v1
...
e ` an ⇒ vn
v = op(v1, . . . , vn)
e ` op(a1, . . . , an) ⇒ v
19
Un interprète simple mais efficace
Tout ceci nous mène à l’interprète canonique pour le λ-calcul en
appel par valeur:
type term = Var of int | Lambda of term | App of term * term
| Constant of int | Primitive of primitive * term list
and value = Int of int | Closure of term * environment
and environment = value list
and primitive = value list -> value
let rec eval env term =
match term with
| Var n -> List.nth env n
| Lambda a -> Closure(a, env)
| App(a, b) ->
let (Closure(c, env’)) = eval env a in
let v = eval env b in
eval (v :: env’) c
| Constant n ->
Int n
| Primitive(p, arguments) ->
p (List.map (eval env) arguments)
20
Sémantique à petits pas avec substitutions explicites
La sémantique à grands pas est utile pour écrire des interprètes,
mais la sémantique à petits pas permet de raisonner plus
facilement sur les évaluations qui bouclent ou qui se bloquent.
La notion d’environnement peut être internalisée dans une
sémantique à petits pas (à base de réductions): le λ-calcul avec
substitutions explicites.
Termes:
Environnements:
a ::= n | λa | a1 a2 | a[e]
e ::= ε | a.e
Règles de réduction de base:
1[a.e]
(n + 1)[a.e]
(λa)[e] b
(λa) b
(a b)[e]
→
→
→
→
→
a
n[e]
a[b.e]
a[b.ε]
a[e] b[e]
(FVar)
(RVar)
(Beta)
(Beta1)
(App)
21
Des calculs de substitutions explicites plus complets
Les règles précédentes sont le minimum nécessaire pour décrire
la réduction faible en appel par valeur.
Pour décrire d’autres stratégies, dont la réduction forte, des
calculs de substitutions explicites plus riches sont nécessaires:
Explicit substitutions, M. Abadi, L. Cardelli, P.L. Curien, J.J. Lévy, Journal of
Functional Programming 6(2), 1996.
Confluence properties of weak and strong calculi of explicit substitutions,
P.L. Curien, T. Hardin, J.J. Lévy, Journal of the ACM 43(2), 1996.
22
Deuxième partie
Machines abstraites
23
Trois modèles d’exécution
• Interprétation:
le contrôle (l’enchaı̂nement des calculs) est exprimé par un terme
du langage source, représenté par un arbre. L’interprète
parcours cet arbre pendant l’exécution.
• Compilation en code natif:
le contrôle est compilé en une séquence d’instructions
machine. Ces instructions sont celles d’un processeur réel, et
sont exécutées en hardware.
• Compilation en code d’une machine abstraite:
le contrôle est compilé en une séquence d’instructions
abstraites. Ces instructions sont celles d’une machine
abstraite: elles ne correspondent pas à celles d’un processeur
hardware existant, mais sont choisies proches des opérations
du langage source.
24
Une machine abstraite pour les expressions arithmétiques
Expressions arithmétiques:
a ::= cst | op(a1, . . . , an)
La machine utilise une pile pour stocker les résultats
intermédiaires.
La compilation est une traduction des expressions en “notation
polonaise inverse”. (Cf. les calculatrices HP.)
C(cst) = CONST(cst)
C(op(a1, . . . , an)) = C(a1); C(a2); . . . ; C(an); I(op)
avec I(+) = ADD, I(−) = SUB, etc.
25
Transitions de la machine abstraite
La machine a deux composantes:
• un pointeur de code c (la prochaine instruction à exécuter);
• une pile s contenant les résultats intermédiaires.
Notations pour les piles: le sommet de pile est à gauche.
empiler v sur s: s −→ v.s
dépiler v de s: v.s −→ s
Transitions de la machine:
État avant
État après
Code
Pile
Code
Pile
CONST(cst); c
s
c
cst.s
ADD; c
n2.n1.s
c
(n1 + n2).s
SUB; c
n2.n1.s
c
(n1 − n2).s
État final: code = ε et pile = v.s.
Le résultat du calcul est alors v.
26
Une machine abstraite pour le λ-calcul en appel par valeur
(Similaire à la machine SECD de Landin et la machine FAM de Cardelli.)
Trois composantes:
• un pointeur de code c (prochaine instruction à exécuter);
• un environnement e associant des valeurs aux variables libres;
• une pile s contenant les résultats intermédiaires et les
adresses de retour.
Schéma de compilation:
C(n) = ACCESS(n)
C(λa) = CLOSURE(C(a); RETURN)
C(a b) = C(a); C(b); APPLY
(Constantes et arithmétique: comme avant.)
27
Transitions
État avant
État après
Code
Env Pile
Code Env Pile
ACCESS(n); c
e
s
c
e
e(n).s
CLOSURE(c0); c e
s
c
e
[c0, e].s
APPLY; c
e
v.[c0, e0].s c0
v.e0
c.e.s
RETURN; c
e
v.c0.e0.s
e0
v.s
c0
• ACCESS(n): empile la valeur de la variable numéro n
• CLOSURE(c0): empile la fermeture [c, e] de c avec
l’environnement courant.
• APPLY: dépile argument, dépile fermeture, sauve code et
environnement courants, saute au code de la fermeture.
• RETURN: reprend le code et l’environnement sauvés sur la pile.
28
Exécution du code d’une machine abstraite
Le code d’une machine abstraite à base de pile peut être
exécuté de deux manières:
• Par expansion des instructions abstraites en séquences
d’instructions d’une machine réelle, p.ex.
CONST(i)
ADD
--->
--->
pushl $i
popl %eax
addl 0(%esp), %eax
• Par interprétation efficace.
L’interprète est typiquement écrit en C (voir prochain
transparent), et calcule environ 10 fois plus vite qu’un
interprète de termes.
29
Un interprète typique pour une machine abstraite
value interpreter(int * start_code)
{
register int * c = start_code;
register value * s = bottom_of_stack;
register environment e;
while(1)
switch
case
case
case
case
case
(*c++) {
CONST:
ADD:
ACCESS:
CLOSURE:
APPLY:
case RETURN:
case STOP:
*s++ = *c++; break;
s[-2] = s[-2] + s[-1]; s--; break;
*s++ = Lookup(e, *c++); break;
*s++ = MakeClosure(*c++, e); break;
arg = *--sp; clos = *--sp;
*sp++ = (value) c; *sp++ = (value) e;
c = Code(clos);
e = AddEnv(arg, Environment(clos));
break;
res = *--sp; e = (environment) *--sp; c = (int *) *--sp;
*sp++ = res; break;
return *--sp;
}
}
30
Une machine abstraite pour l’appel par nom: la machine
de Krivine
Comme avant, trois composantes dans cette machine:
code c, environnement e, pile s.
Cependant, la pile et l’environnement ne contiennent pas des
valeurs mais des suspensions: des fermetures [c, e] représentant
des expressions e dont l’évaluation est retardée jusqu’à ce qu’on
ait besoin de leur valeur.
Schéma de compilation:
C(n) = ACCESS(n)
C(λa) = GRAB; C(a)
C(a b) = PUSH(C(b)); C(a)
31
Transitions de la machine de Krivine
État avant
Code
État après
Env Pile
Code Env
Pile
c0
s
e0
ACCESS(n); c e
s
GRAB; c
e
[c0, e0].s c
[c0, e0].e s
PUSH(c0); c
e
s
e
c
si e(n) = [c0, e0]
[c0, e].s
• ACCESS(n): trouve la suspension associée à la variable n, et
entreprend de l’évaluer
• GRAB: dépile un argument et l’ajoute à l’environnement
(étape de β-réduction)
• PUSH(c): construit une suspension pour c et l’empile.
32
Pourquoi ça marche?
La pile représente l’épine dorsale d’applications en cours.
Le code représente la partie en bas à gauche de l’épine dorsale.
ACCESS
@
@
n[e]
Code
a2[e2]
a1[e1]
GRAB
@
@
(λa)[e0]
a2[e2]
@
a[a1[e1].e0] a2[e2]
a1[e1]
Pile
33
L’appel par nom en pratique
Les machines abstraites réalistes pour l’appel par nom sont plus
complexes que la machine de Krivine en deux points:
• Constantes et opérations primitives strictes:
Les opérateurs comme l’addition entière sont stricts: ils doivent évaluer
entièrement leurs arguments. Des mécanismes supplémentaires sont
nécessaires pour réduire strictement des sous-expressions en leurs valeurs.
• Partage des calculs (évaluation paresseuse):
L’appel par nom réduit une expression à chaque fois qu’on a besoin de sa
valeur. L’évaluation paresseuse fait le calcul la première fois, puis cache
le résultat pour les fois suivantes.
Voir p.ex. Implementing lazy functional languages on stock hardware: the
Spineless Tagless G-machine, S.L. Peyton Jones, Journal of Functional
Programming 2(2), Apr 1992.
34
Preuves de correction de machines abstraites
À ce point du cours, nous avons deux notions d’évaluation pour
un même terme:
1. Évaluation directe du terme avec des environnements:
∗
a[e] → v
ou
e ` a ⇒ v.
2. Compilation, puis exécution du code par la machine
abstraite:




c = C(a)
c=ε
∗




→  e = ... 
 e=ε

s=ε
s = v...
Est-ce que ces deux notions coincident? Est-ce que la machine
abstraite calcule le bon résultat?
35
Correction partielle vis-à-vis de la sémantique grands pas
Le schéma de compilation est compositionnel: chaque
sous-terme est compilé en du code qui évalue le sous-terme et
dépose sa valeur au sommet de la pile.
Ceci suit exactement une dérivation de e ` a ⇒ v dans la
sémantique grands pas. Cette dérivation contient des
sous-dérivations e0 ` a0 ⇒ v 0 pour chaque sous-expression a0.
Théorème: si e ` a ⇒ v, alors

C(a); k

 C(e)
s




∗
→
k

 C(e)



C(v).s
36
Le schéma de compilation C(·) se prolonge aux valeurs et aux
environnements comme suit:
C(cst) = cst
C((λa)[e]) = [(C(a); RETURN), C(e)]
C(v1 . . . vn.ε) = C(v1) . . . C(vn).ε
Le théorème se prouve par récurrence sur la dérivation de
e ` a ⇒ v. Nous détaillons le seul cas intéressant: l’application
de fonction.
e ` a ⇒ (λc)[e0]
e ` b ⇒ v0
v 0.e0 ` c ⇒ v
e`a b⇒v
37
( C(a); C(b); APPLY; k | C(e) | s )
↓∗
hyp. rec. sur première prémisse
( C(b); APPLY; k | C(e) | [(C(c); RETURN), C(e0)].s )
↓∗
hyp. rec. sur seconde prémisse
( APPLY; k | C(e) | C(v 0).[(C(c); RETURN), C(e0)].s )
↓
transition APPLY
( C(c); RETURN | C(v 0.e0) | k.C(e).s )
↓∗
hyp. rec. sur troisième prémisse
( RETURN | C(v 0.e0) | C(v).k.C(e).s )
↓
transition RETURN
( k | C(e) | C(v).s )
38
Vers une preuve de correction totale
Le théorème précédent montre la correction de la machine
abstraite pour les termes qui terminent. Mais si a ne termine
pas, e ` a ⇒ v est faux, et nous ne savons rien sur ce que fait le
code compilé.
(Il peut boucler, mais aussi bien s’arrêter et répondre “42”.)
Pour montrer la correction sur tous les termes (terminants ou
non), il faut établir une simulation entre les transitions de la
machine et les réductions du langage source:
Chaque transition de la machine correspond à zéro, une
ou plusieurs réductions du langage source.
Voir Functional Runtimes within the Lambda-Sigma Calculus, T. Hardin, L.
Maranget, B. Pagano, J. Func. Prog 8(2), 1998.
39
La simulation
terme a
compilation
réd *
décompilation
état
initial
transition
terme a1
réd *
décompilation
état 1
transition
terme a2
décompilation
état 2
Problème: certains états intermédiaires de la machine ne
correspondent pas à la compilation d’un terme source.
Solution: on va construire une fonction de décompilation
D : Etats → Termes qui est définie sur tous les états
intermédiaires et qui est inverse à gauche de la fonction de
compilation.
40
La fonction de décompilation
Idée: la décompilation est une variante symbolique de la
machine abstraite: elle reconstruit des termes sources au lieu
d’effectuer vraiment les calculs.
Décompilation des valeurs:
D(cst) = cst
D([c, e]) = (λa)[D(e)] si c = C(a); RETURN
Décompilation des environnements et des piles:
D(v1 . . . vn.ε) = D(v1) . . . D(vn).ε
D(. . . v . . . c.e . . .) = . . . D(v) . . . c.D(e) . . .
Décompilation des états concrets:
D(c | e | s) = D(c | D(e) | D(s))
41
Décompilation, suite
Décompilation des états abstraits (E, S déjà décompilés):
D(ε | E | a.S) = a
D(CONST(cst); c | E | S) = D(c | E | cst.S)
D(ACCESS(n); c | E | S) = D(c | E | E(n).S)
D(CLOSURE(c0); c | E | S) = D(c | E | (λa)[E].S)
si c0 = C(a); RETURN
D(RETURN; c | E | a.c0.E 0.S) = D(c0 | E 0 | a.S)
D(APPLY; c | E | b.a.S) = D(c | E | (a b).S)
D(I(op); c | E | an . . . a1.S) = D(c | E | (op(a1, . . . , an)).S)
42
Lemmes de correction
Simulation: si D(S) est défini, et la machine fait une transition
∗
de S vers S 0, alors D(S 0) est défini et D(S) → D(S 0).
Progression: si S n’est pas un état final, et D(S) est défini et se
réduit, alors la machine peut faire une transition depuis S.
Non-bégaiement: il existe une mesure entière |S| sur les états
machine telle que si la machine fait une transition silencieuse de
S vers S 0 (c.à.d. D(S) = D(S 0)), alors |S| > |S 0|.
États initiaux: D(C(a) | ε | ε) = a si a est clos.
États finaux: D(ε | e | v.s) = D(v).
43
Théorème de correction forte
Théorème: Soit a un terme clos, et S = (C(a) | ε | ε).
∗
• Si a → v, alors la machine abstraite lancée dans l’état S
termine et renvoie la valeur C(v).
• Si a se réduit à l’infini, la machine lancée dans l’état S
effectue une infinité de transitions.
44
Troisième partie
La réduction forte
45
La réduction forte
Permet de réduire sous un λ:
a → a0
λx.a → λx.a0
Le résultat du calcul est alors la forme normale (FN) du
λ-terme, et non plus sa forme normale faible (FNF):
∗
(FNF)
∗
(FN)
(λx. λy. (x + 1) + y) 3 → λy. (3 + 1) + y
→ λy. 4 + y
Cette notion de calcul est utilisée pour:
• L’optimisation des programmes, la spécialisation de
fonctions, l’évaluation partielle.
• La preuve sur machine (système Coq): deux fonctions qui
ont la même forme normale sont interchangeables. Toute
propriété de l’une est valide pour l’autre.
46
Lien entre réduction forte et spécialisation de fonctions
let rec power = λn. λx. if n = 0 then 1 else x * power (n-1) x
La réduction forte de l’application partielle power 4 est
λx. x * (x * (x * (x * 1)))
qui est bien une forme spécialisée de l’élévation à la puissance
pour l’exposant 4.
Autre exemple célèbre: si on voit un interprète comme une
fonction
programme → données d’entrée → résultat
la spécialisation de cet interprète par-rapport à un programme
fixé est une forme de compilation.
47
Allure d’une forme normale
Les formes normales sont exactement les termes de la
grammaire ci-dessous:
Formes normales:
n ::= x
| λx.n
| n1 n2
| cst
| op(n1, . . . , nk )
si n1 6= λx.a
si ∃i. ni 6= cst
48
Une stratégie de calcul des formes normales
(A compiled implementation of strong reduction, B. Grégoire and X. Leroy,
Int. Conf. Functional Programming 2002.)
Idée: pour normaliser fortement a, commençons donc par
calculer sa valeur v (forme normale faible).
Si v = cst, c’est la forme normale de a. C’est gagné!
Si v = λx.a0, on calcule récursivement N F (a0) (la f.n. de a).
Alors, N F (a) = N F (λx.a0) = λx.N F (a0).
Plus généralement: les formes normales s’obtiennent par une
alternance de réduction faible symbolique et de relecture
(transformation valeurs → NF).
La réduction faible symbolique est la réduction faible de termes
contenant des variables libres (p.ex. a0 ci-dessus, où x peut être
libre).
49
La réduction faible symbolique
Valeurs:
b ::= x | v | b1 b2 | op(b∗)
v ::= cst | λx.b | [k]
Accumulateurs:
k ::= x̃ | k v | op(v ∗, k, b∗)
Termes étendus:
Règles de réduction:
(λx.b) v → a{x ← v}
(βv )
[k] v → [k v]
(βs)
op(v ∗) → v 0 if v 0 = op(v ∗) (δv )
op(v ∗, [k], b∗) → [op(v ∗, k, b∗)]
(δs)
Stratégie: comme précédemment (gauche-droite; pas de
réductions sous les λ).
50
Calcul de la forme normale
Normalisation forte = (réduction symbolique faible; relecture) *
Soit F N F (b) la forme normale faible symbolique de b.
F N (b) = R(F N F (b))
R(λx.b) = λy.F N ((λx.b) [ỹ])
y nouvelle variable
R(cst) = cst
R([k]) = R(k)
R(x̃) = x
R(k v) = R(k) R(v)
R(op(v ∗, k, b∗)) = op(R(v ∗), R(k), F N (b∗))
51
Pourquoi ça marche?
Lemme 1: F N (b) est un n-terme, c.à.d. un λ-terme pur en
forme normale.
Traduction des b-termes en a-termes: [k] = k
∗
x̃ = x.
∗
Lemme 2: si b → b0 alors b → b0.
∗
Lemme 3: v → R(v).
∗
Corollaire: b → F N (b).
Remarque: a = a pour tous les λ-termes purs a.
∗
Corollaire: a → F N (a).
Lemme 4: si a est fortement normalisant, le calcul de F N (a)
termine.
(On réduit toujours des sous-termes de a à renommage près.)
Conclusion: si a est fortement normalisant, F N (a) existe et est
la forme normale de a.
52
Une machine abstraite pour la réduction symbolique faible
Nous allons étendre la machine abstraite de la 2e partie pour
qu’elle effectue la réduction symbolique faible.
Idée: représentons la valeur [k] par une pseudo-fermeture
[ACCU, k̂] où k̂ est un codage machine de k.
L’instruction ACCU se content d’enregistrer son argument et de
renvoyer une pseudo-fermeture à l’appelant.
État avant
Code
Env Pile
ACCU; c e
État après
Code Env Pile
v.c0.e0.s c0
e0
[ACCU, v.e].s
53
L’instruction ACCU “piège” l’instruction APPLY habituelle et lui fait
faire exactement ce qu’il faut lorsque la fonction appliquée
s’évalue en [k].
Code
Env Pile
APPLY; c e
v.[ACCU, k̂].s
ACCU
k̂
v.c.e.s
c
e
[ACCU, v.k̂].s
Le passage de [ACCU, k̂] à [ACCU, v.k̂]] implémente exactement la
règle de β-réduction symbolique [k] v → [k v].
(La réduction symbolique des applications d’opérateurs nécessite
des changements plus importants; nous n’en parlerons pas ici.)
54
Quatrième partie
Introduction à la compilation optimisante
55
La compilation optimisante
Objectifs: produire du code machine rapide et compact. Bien
exploiter les possibilités des processeurs hardware.
Les approches à base de machines abstraites ne suffisent pas:
• Interprétation du code abstrait:
surcoût de l’interprétation (facteur 5-10).
• Expansion en code machine:
beaucoup d’inefficacités et de redondances.
CONST(i)
ADD
--->
pushl $i
popl %eax
addl 0(%esp), %eax
(Au lieu de addl 0(%esp), $i)
56
Comment produire du code machine efficace?
• Ne pas introduire d’inefficacités au cours de la compilation.
• Appliquer des optimisations pour éliminer des inefficacités.
Les inefficacités visées sont de 3 formes:
• présentes dans le code source
let x = 1 + 2 in ...
--->
let x = 3 in ...
• implicites dans le code source
a.[i] + b.[i]
--->
load(a + i * 4) + load(b + i * 4)
• introduites par des passes précédentes
let x = 1 in
let y = x + 2 in
y
--->
let x = 1 in
let y = 3 in
3
--->
3
57
Comment produire du code machine efficace?
Procéder en plusieurs passes de transformation successives.
Utiliser des langages intermédiaires pour tailler des marches
entre le langage source et le code machine.
Source
Intermédiaire 1
Intermédiaire 2
Intermédiaire 3
Assembleur
Code machine
58
Le choix des langages intermédiaires
Un langage intermédiaire explicite certains aspects du
programme et se prête aux optimisations portant sur ces
aspects.
Exemple 1: l’expansion en ligne de fonctions se fait idéalement
sur un langage de type λ-calcul. Serait beaucoup plus difficile
sur des langages de plus bas niveau.
(fun x → a) b
--->
a{x ← b}
Exemple 2: la factorisation de sous-expressions communes est
plus efficace sur un langage intermédiaire où les calculs
d’adresses sont explicites que sur le langage source.
a[i] + b[i]
load(a + i*4) + load(b + i*4)
--->
??
--->
let t = i * 4 in
load(a + t) + load(b + t)
59
Exemple: le compilateur OCaml
Arbre de
parsing
typage
A.S.T.
typé
élimination
filtrage, objets
λ-calcul
enrichi
explicitation fermetures
C--
représentations
des données
λ-calcul
+ fermetures
Bytecode
mach.virt.
60
optimisations
C--
ordonner les calculs
sélection d’instructions
Langage RTL
avec registres virtuels
allocation
de registres
RTL linéaire
(avec goto)
ordonnancement
linéarisation
du contrôle
Langage RTL
avec registres réels
et blocs de pile
génération assembleur
Code
assembleur
61
Le langage intermédiaire RTL-CFG
Aussi appelé “code 3 adresses”. Intermédiaire entre un langage
de haut niveau (identificateurs, expressions, structures de
contrôle) et le langage machine (registres, instructions, goto):
• 1 opération ≈ 1 instruction du processeur.
• Opérandes: des temporaires (pseudo-registres) en nombre
arbitraire, préservés lors de l’appel de fonctions.
• Contrôle: non structuré, exprimé par un graphe de flot.
62
Exemple de RTL
double avg(int * tbl,
int size)
{
double s = 0;
int i;
for (i=0; i<size; i++)
s += tbl[i];
return s / size;
}
average(tbl: int, size: int): float
var fs, fc, fd, f1: float
var ri, ra, rb: int
I1:
I2:
I3:
I4:
I5:
I6:
I7:
I8:
I9:
I10:
I11:
fs = 0.0;
--> I2
ri = 0;
--> I3
if (ri >= size)
--> I9/I4
ra = ri * 4
--> I5
rb = load(tbl + ra) --> I6
fc = float_of_int(rb) -> I7
fs = fs +f fc
--> I8
ri = ri + 1
--> I3
fd = float_of_int(size) --> I10
f1 = fs /f fd
--> I11
return f1
63
Syntaxe de RTL
Registres:
Instructions:
Fonctions:
r
instr ::= nop
| r = op(r1, . . . , rn)
| r = load(mode, r1, . . . , rn)
| store(r, mode, r1, . . . , rn)
| if (cond, r1, . . . , rn)
| r = call(addr, r1, . . . , rn)
| r = callind(r, r1, . . . , rn)
| return r
f n ::= { params = r∗;
vars = r∗;
code = ` → instr ∗ `∗;
start = ` }
paramètres
variables locales
graphe de flot
point d’entrée
64
Opérateurs, modes d’adressage, conditions
Ceux du processeur cible. Exemple du PowerPC:
• Opérateurs: constantes, move, opérateurs logiques,
arithmétique entière, arithmétique flottante, . . .
Opérations composites: fmadd (r1 + r2 × r3), . . .
• Modes d’adressage: r + cst ou r1 + r2.
• Conditions: comparaisons entières, comparaisons flottantes,
tests de bits.
65
Sémantique opérationnelle de RTL
P, f ` (`, R, S) → (`0, R0, S 0)
Programme P : adresse → fonction.
Fonction courante f .
Point de contrôle ` (avant), `0 (après).
État des registres (registre → valeur) R, R0.
État mémoire (adresse → valeur) S, S 0.
f.code(`) = (nop, [`0])
P, f ` (`, R, S) → (`0, R, S)
f.code(`) = (r = op(r1, . . . , rn), [`0])
v = op(R(r1), . . . , R(rn))
P, f ` (`, R, S) → (`0, R{r ← v}, S)
66
f.code(`) = (r = load(mode, r1, . . . , rn), [`0])
a = mode(R(r1), . . . , R(rn))
P, f ` (`, R, S) → (`0, R{r ← S(a)}, S)
f.code(`) = (store(r, mode, r1, . . . , rn), [`0])
a = mode(R(r1), . . . , R(rn))
P, f ` (`, R, S) → (`0, R, S{a ← R(r)})
f.code(`) = (if(cond, r1, . . . , rn), [`1; `2])
cond(R(r1), . . . , R(rn)) 6= 0
P, f ` (`, R, S) → (`1, R, S)
f.code(`) = (if(cond, r1, . . . , rn), [`1; `2])
cond(R(r1), . . . , R(rn)) = 0
P, f ` (`, R, S) → (`2, R, S)
67
f.code(`) = (r = call(addr, r1, . . . , rn), [`0])
P (addr) = f 0
R0 = (f 0.params 7→ R(r1), . . . , R(rn))
∗
P, f 0 ` (f 0.start, R0, S) → (v, S 0)
P, f ` (`, R, S) → (`0, R{r ← v}, S 0)
f.code(`) = (return r, [ ])
v = R(r)
∗
P, f ` (`, R, S) → (v, S)
P, f ` (`, R, S) → (`1, R1, S1)
∗
P, f ` (`1, R1, S1) → (v, S 0)
∗
P, f ` (`, R, S) → (v, S 0)
68
Optimisations classiques sur RTL
De nombreuses optimisations classiques s’effectuent sur le RTL.
• Constant propagation
ra
rb
rc
rd
=
=
=
=
1
2
ra + rb
rx - ra
-->
ra
rb
rc
rd
=
=
=
=
1
2
3
rx + (-1)
• Dead code elimination
ra
rb
rc
//
= 1
= 2
-->
= 3
ra inutilisé ensuite
nop
rb = 2
rc = 3
69
• Common subexpression elimination
rc = ra
rd = ra + rb
re = rc + rb
rc = ra
rd = ra + rb
re = rd
-->
• Hoisting of loop-invariant computations
I: rc = ra + rb
...
... -> I
-->
rc = ra + rb
I: ...
... -> I
• Induction variable elimination
ri =
I: ra =
rb =
...
ri =
0
ri * 4
rp + ra
ri + 1 -> I
-->
ri =
rb =
I: ...
rb =
ri =
0
rp
rb + 4
ri + 1 -> I
• . . . et bien d’autres encore.
70
Optimisation et analyse statique
Exemple de la propagation des constantes:
ra
rb
rc
rd
=
=
=
=
1
2
ra + rb
rx - ra
-->
ra
rb
rc
rd
=
=
=
=
1
2
3
rx + (-1)
Effectuer à la compilation les opérations arithmétiques dont les
opérandes sont statiquement connues.
Spécialiser les opérations dont un des opérandes est
statiquement connu.
Problème: comment déterminer statiquement que ra et rb sont
constants, et prédire leur valeur?
Solution: analyse statique de type dataflow.
71
Peut-on faire confiance au compilateur?
Les transformations effectuées par un compilateur optimisant
réaliste sont complexes; une erreur est vite arrivée. . .
Contexte: le logiciel critique (embarqué) devant passer un
certain niveau de certification.
Certification par des tests systématiques:
• Ce qui est testé est le code exécutable produit par le
compilateur.
• Les bugs du compilateur sont détectés en même temps que
ceux du programme.
Certification par méthodes formelles (preuve, model-checking):
• Ce qui est prouvé est le code source, pas le code exécutable.
• Les bugs du compilateur peuvent invalider l’approche.
• Pratique actuelle: revues manuelles du code assembleur
produit par le compilateur (sans optimisations).
72
Un maillon faible
Dans le monde des méthodes formelles, le compilateur est un
“maillon faible” entre:
• un programme source vérifié formellement;
• un processeur également vérifié formellement.
73
Certifier le compilateur
Appliquer les méthodes formelles au compilateur lui-même.
Établir formellement une propriété Prop du code compilé C et
du code source S, comme par exemple:
• Prop (C , S ) = “C et S ont la même sémantique”
(i.e. sont observationellement équivalents).
• Prop (C , S ) = “C satisfait une certaine spec fonctionnelle”
• Prop (C , S ) = “C est bien typé”
74
Approche 1: validation de traductions
Translation validation (A. Pnueli et al; X. Rival, POPL 2004)
Procède par analyse statique du code compilé.
Verif (C, S) = OK ⇒ Prop (C, S) avec C = Compil (S)
Pas de modifications dans le compilateur: le compilateur est
traité comme une boı̂te noire.
Rencontre des problèmes de type “décompilation” (retrouver la
structure de S dans le code compilé C).
Pour obtenir des garanties formelles, le vérifieur doit être certifié.
75
Approche 2: compilateur certifiant
Proof-carrying code (P. Lee, G. Necula, A. Appel)
Le compilateur produit non seulement du code compilé mais
aussi un terme de preuve établissant la propriété désirée.
∀S. P ` Prop (C, S)
avec (C, P ) = Compil (S)
La preuve peut être vérifiée par un outil indépendant et simple
⇒ intérêt pour le code mobile.
Le compilateur doit être soit réécrit, soit fortement instrumenté
pour produire le terme de preuve.
76
Approche 3: compilateur certifié
Prouver le compilateur lui-même, une fois pour toute et pour
tous les programmes sources.
Preuve ` ∀S.Prop (Compil (S), S)
Compilateur écrit dans le langage de spécification du prouveur,
ou dans un langage qui s’importe facilement dans ce dernier.
77
Unifier validation de traductions et compilateurs certifiants
Translation validation et Proof-carrying code sont deux
instances d’une démarche plus générale:
• Le compilateur produit du code C et une annotation A
(aussi appelé certificat): (C, A) = Compil (S)
• Le vérifieur établit P rop(C, S) en s’aidant de A:
Verif (C, S, A) = OK ⇒ Prop (C, S)
Techno
Annotations
Vérifieur
Proof-carrying code
Terme de preuve
Proof-checking
Translation validation pure
Rien
Analyse statique
Translation validation pratique
Infos de debug, etc
Analyse statique
Type-preserving compilation, typed assembly language, bytecode verification
Annotations de types
Type-checking
78
De certifié à certifiant ou validé
Soit Compil un compilateur certifié et P la preuve constructive
de ∀S.Prop (Compil (S), S).
Compil 0(S) = (Compil (S), P (S)) est un compilateur certifié.
Verif (C, S, A) = if C = Compil (S) then OK else Error
est un validateur (peu utile).
79
De certifiant/validé à certifié
Soit Compil un compilateur certifiant/validé,
Verif le vérifieur correspondant,
et P une preuve de ∀C, S, A.Verif (C, S, A) = OK ⇒ Prop (C, S).
Compil 0 ci-dessous est un compilateur certifié:
Compil 0(S) = let (C, A) = Compil (S) in
if Verif (C, S, A) = OK
then C
else undefined
En effet, si C = Compil 0 est défini, on a Verif (C, S, A) = OK et
donc Prop (C, S).
80
Propriétés de compositionnalité
Soient Compil 1 : S → I et Compil 2 : I → C deux (passes de)
compilateurs certifiés.
Compil 2 ◦ Compil 1 : S → C est un compilateur certifié pourvu que
la relation Prop soit transitive:
Prop (S, Compil 1(S)) ∧ Prop (Compil 1(S), Compil 2(Compil 1(S)))
⇒ Prop (S, Compil 2(Compil 1(S)))
81
Propriétés de compositionnalité
Si Compil 1 et Compil 2 sont deux (passes de) compilateurs
certifiants, et Verif 1, Verif 2 les vérifieurs correspondants,
Compil (S) = let (I, A1) = Compil 1(S) in
let (C, A2) = Compil 2(I) in
(C, (A1, I, A2))
Verif (C, S, (A1, I, A2)) = if Verif 1(I, S, A1) = OK
and Verif 2(C, I, A2) = OK
then OK else Error
forment un compilateur certifiant.
82
En résumé
On peut établir la correction d’un compilateur en menant pour
chaque passe de transformation ou d’analyse statique
• Ou bien une preuve de correction de la transformation,
• ou bien une preuve de correction d’un vérifieur qui valide la
transformation effectuée.
Nous allons maintenant étudier la preuve de correction de
transformations à base d’analyses dataflow.
83
Cinquième partie
Optimisations à base d’analyses dataflow
84
Les inéquations dataflow avant
À chaque point ` on associe une approximation X(`) de l’état
des registres en ce point.
Les approximations sont prises dans un demi-treillis L.
(a1 ≥ a2 signifie que l’approximation a1 est plus grossière que a2.)
Dataflow avant: trouver une solution X aux inéquations
X(s) ≥ T (`, X(`))
X(`) ≥ a`
si s est un successeur de `
si (`, a`) est un point d’entrée
Ici, T est une fonction de transfer T : point × L → L qui relie
l’approximation avant (a) et après (T (`, a)) l’exécution de
l’instruction au point `.
Les points d’entrée sont une liste de couples (`, a`) qui permet
d’imposer des contraintes en des points particulier de la fonction.
85
Les inéquations dataflow arrière
Pour certaines analyses, la fonction de transfer T calcule
l’approximation avant (T (`, a)) à partir de l’approximation après
(a) l’exécution de l’instruction au point `. D’où:
Dataflow arrière: trouver une solution X aux inéquations
X(`) ≥ T (s, X(s))
X(`) ≥ a`
si s est un successeur de `
si (`, a`) est un point d’entrée
Un problème dataflow arrière est équivalent à un problème
dataflow avant sur le dual du graphe de flot de contrôle
(renversement des arcs).
86
Exemple: la propagation des constantes
Le demi-treillis des valeurs abstraites: les fonctions
reg → (> | value | ⊥)
À chaque registre, on associe l’info valeur inconnue / valeur
statiquement connue / pas de valeur (code inatteignable).
Idée de l’analyse: ` : r = op(r1, . . . , rn).
Si r1, . . . , rn ont des valeurs connues au point `, alors la valeur de
r est connue aux successeurs de `.
Sinon, la valeur de r est inconnue (>) aux successeurs de `.
→ Une analyse dataflow avant.
87
La fonction de transfer et les points d’entrée
a0 = T (`, a) est défini par cas sur l’instruction au point `:
• Cas r = op(r1, . . . , rn):
– si a(r1) = v1, . . . , a(rn) = vn (valeurs connues),
a0 = a{r ← v} avec v = op(v1, . . . , vn).
– sinon, a0 = a{r ← >} (résultat non prévisible).
• Cas r = load(. . .) ou r = call(. . .) ou r = callind(. . .):
a0 = a{r ← >} (résultat non prévisible).
• Cas store, if, return:
a0 = a (pas de modification des registres).
Contraintes de points d’entrée: X(f.start) ≥ >
(Les valeurs des registres à l’entrée de la fonction ne sont pas
prévisibles.)
88
Résolution des inéquations dataflow
L’algorithme de la worklist de Kildall:
Initialisation: pour tout point `,
in(`) = a si (`, a) est un point d’entrée, ⊥ sinon
out(`) = ⊥
W = l’ensemble de tous les points de la fonction
89
Itération de point fixe:
Tant que W n’est pas vide:
Choisir ` dans W
W = W \ {`}
out(`) = T (`, in(`))
Pour chaque successeur s de `:
t = lub(in(s), out(`))
Si t 6= in(s):
in(s) = t
W = W ∪ {s}
Fin Si
Fin Pour tout
Fin Tant que.
À la sortie, in est une solution des inéquations dataflow.
90
Correction de l’algorithme de Kildall
Terminaison: à chaque étape, ou bien l’un des in(`) augmente
strictement, ou bien la taille de W diminue.
→ Termine si le treillis L est bien fondé (pas de suites infinies
strictement croissantes).
Deux invariants:
in(s) ≥ T (`, in(`))
in(`) ≥ in0(`)
si ` ∈
/ W et s est un successeur de `
pour tout `
91
Digression: optimalité vs. correction
L’algorithme de Kildall calcule en fait une solution minimale aux
inéquations dataflow avant. Cette solution vérifie:
X(`) = lub {T (p, X(p)) | p prédecesseur de `}
C’est cette forme qui est utilisée dans la littérature sous le nom
“équations dataflow”.
Cependant, l’égalité X(`) = . . . n’est pas nécessaire pour prouver
la correction de l’analyse: l’inégalité X(`) ≥ . . . suffit.
92
Propagation des constantes: correction de l’analyse
Un jeu de registres correspond à un résultat d’analyse si tous les
registres dont la valeur est statiquement connue ont bien cette
valeur.
R : a si pour tout registre r,
• ou bien a(r) = >
• ou bien a(r) = v et R(r) = v pour une certaine valeur v.
Remarque: si a0 ≥ a et R : a, alors R : a0.
93
Propagation des constantes: correction de l’analyse
Si les registres avant l’exécution d’une instruction correspondent
aux résultats de l’analyse en ce point, alors les registres après
l’exécution correspondent aux résultats de l’analyse au point
successeur.
Notant A le résultat de l’analyse statique de la fonction f :
Si P, f ` (`, R, S) → (`0, R0, S 0) et R : A(`), alors R0 : A(`0).
94
Propagation des constantes: transformation du code
On réécrit les instructions en fonction des résultats de l’analyse:
• Instruction arithmétique dont tous les arguments sont
statiquement connus: remplacée par r = v.
• Opération arithmétique dont un des arguments est connu:
remplacée par sa spécialisation (p.ex. r = r1 + r2 devient
r = r1 + v).
• Branchement conditionnel dont tous les arguments sont
statiquement connus: remplacé par nop vers le bon
successeur.
On note transf(f ) et transf(P ) le résultat de cette
transformation.
95
Propagation des constantes: correction de la
transformation
Un résultat de simulation:
Si P, f ` (`, R, S) → (`0, R0, S 0) et R : A(`), alors
transf(P ), transf(f ) ` (`, R, S) → (`0, R0, S 0).
∗
Si P, f ` (`, R, S) → (v, S 0) et R : A(`), alors
∗
transf(P ), transf(f ) ` (`, R, S) → (v, S 0).
96
Le diagramme de simulation
(`, R, S)
R : A(`)
exécution
(`0, R0, S 0)
Code avant
transformation
(`, R, S)
exécution
R0
:
A(`0)
(`0, R0, S 0)
Code après
transformation
97
Second exemple: analyse de durée de vie et applications
Un registre r est vivant en un point p si une instruction
atteignable depuis p utilise r, et r n’est pas redéfini entre temps.
L’analyse de durée de vie (liveness analysis) détermine en
chaque point l’ensemble des registres vivants en ce point.
Application 1: élimination de code mort.
` : r = op(r1, . . . , rn) ou ` : r = load(mode, r1, . . . , rn)
Si r n’est pas vivant en `, on peut supprimer cette instruction.
Application 2: allocation de registres (voir plus loin).
98
Analyse de durée de vie
Le demi-treillis des valeurs abstraites: les ensembles de registres
(les registres vivants en ce point). Ordonnés par ⊆, l.u.b =
union.
Idée de l’analyse: p : r = op(r1, . . . , rn).
Si r est vivant en p, aux prédécesseurs de p il est mort, et
r1, . . . , rn sont vivants.
→ Une analyse dataflow arrière.
99
La fonction de transfer
a0 = T (`, a) est défini par cas sur l’instruction au point `:
• Cas r = op(r1, . . . , rn) ou r = load(mode, r1, . . . , rn):
Si r ∈
/ a,
Si r ∈ a,
a0 = a (instruction inutile).
a0 = (a \ {r}) ∪ {r1, . . . , rn}.
• Cas r = call(r1, . . . , rn):
a0 = (a \ {r}) ∪ {r1, . . . , rn}.
• Cas store(mode, r1, . . . , rn) ou if(cond, r1, . . . , rn) ou
return(r1):
a0 = a ∪ {r1, . . . , rn}.
Contraintes de points d’entrée: aucune.
100
Exemple de durées de vie
average(tbl: int, size: int): float
var fs, fc, fd, f1: float
var ri, ra, rb: int
I1:
I2:
I3:
I4:
I5:
I6:
I7:
I8:
I9:
I10:
I11:
fs = 0.0;
ri = 0;
if (ri >= size)
ra = ri * 4
rb = load(tbl + ra)
fc = float_of_int(rb)
fs = fs +f fc
ri = ri + 1
fd = float_of_int(size)
f1 = fs /f fd
return f1
-->
-->
-->
-->
-->
-->
-->
-->
-->
-->
I2
I3
I9/I4
I5
I6
I7
I8
I3
I10
I11
[tbl,size,fs]
[tbl,size,ri,fs]
[tbl,size,ri,fs]
[tbl,size,ri,fs,ra]
[tbl,size,ri,fs,rb]
[tbl,size,ri,fs,fc]
[tbl,size,ri,fs]
[tbl,size,ri,fs]
[fs,fd]
[f1]
[]
101
Application à l’allocation de registres
On dit que deux registres r1 et r2 interfèrent s’ils sont
simultanément vivants en un point du programme.
(Exception: si r1 et r2 ont toujours la même valeur, p.ex. si la
seule définition de r2 est r2 = r1, ils n’interfèrent pas.)
Si r1 et r2 n’interfèrent pas, on peut les remplacer par un unique
registre r.
→ Permet de minimiser le nombre de registres nécessaires par
coloriage du graphe représentant la relation d’interférence.
→ Si ce nombre est ≤ au nombre de registres physiques, on a
réussi une allocation de registres.
→ Sinon, c’est une bon point de départ pour déterminer quels
pseudo-registres vont dans un registre physiques et quels
pseudo-registres vont dans des emplacements de pile.
102
Construction du graphe d’interférences
Graphe non orienté. Noeuds: pseudo-registres ou registres
physiques. Arc entre 2 registres si et seulement si ils interfèrent.
Construction efficace du graphe d’interférences:
Pour chaque instruction ` de la fonction:
• Si ` : rd = rs (affectation simple), ajouter des arcs entre rd et
tous les registres autres que rs et rd vivants en `.
• Sinon, si ` est une instruction définissant le registre r:
ajouter des arcs entre r et tous les registres autres que r
vivants en `.
• Si ` est un appel de procédure: ajouter en plus des arcs
entre les registres vivants en ` et tous les registres réels non
préservés pendant l’appel.
103
Exemple de graphe d’interférences
fs
ri
size
ra
tbl
rb
f1
fc
fd
104
Heuristique de coloriage du graphe
Il faut attribuer à chaque noeud un registre réel (ou si pas
possible un emplacement de pile) distinct de ceux attribués à ses
voisins.
Lemme de Kempe: si un noeud n d’un graphe G est de degré
< k, et si G \ n est k-colorable, alors G est k-colorable.
Soit N le nombre de registres réels disponibles. On peut donc
enlever du graphe d’interférence tous les noeuds de degré < N ,
et les colorier en dernier: il sera toujours possible de leur
attribuer un registre.
105
Coloriage optimiste (Briggs et al)
S: une pile de noeuds, initialement vide.
Tant que le graphe G contient des pseudo-registres:
Choisir r pseudo-registre ∈ G de degré < N si possible,
sinon de degré maximal.
Empiler r sur S.
Enlever r de G.
Fin Tant que
Tant que la pile S n’est pas vide:
Dépiler r de S.
Remettre r dans G.
Attribuer à r un registre physique différent de tous ceux
attribués à ses voisins dans G.
Si pas possible, attribuer à r un emplacement de pile différent
de tous ceux attribués à ses voisins dans G.
Fin Tant que
106
Transformation du code
Analyse de durée de vie; construction du graphe d’interférence;
coloriage de ce dernier
→ une affectation de registres σ : reg → reg.
Transformation du code:
• On renomme les registres comme indiqué par σ.
• On remplace par nop les instructions pures (op, load) dont le
registre résultat n’est pas vivant.
• On remplace par nop les instructions rd = rs telles que
σ(rs) = σ(rd).
107
Correction de la transformation
Le registre r du code initial est renommé en σ(r) dans le code
optimisé.
Pour que ce soit correct, il faut que la valeur de r dans le code
initial soit égale à la valeur de σ(r) dans le code transformé à
chaque point où r est vivant.
Formellement, si A est le résultat de l’analyse de durée de vie:
` ` R1 ∼ R2 ssi R1(r) = R2(σ(r)) pour tout r ∈ A(`)
108
Le résultat de simulation
0 tel
Si P, f ` (`, R, S) → (`0, R0, S 0) et ` ` R ∼ R1, alors il existe R1
0 , S 0 ) et `0 ` R0 ∼ R0 .
que transf(P ), transf(f ) ` (`, R1, S) → (`0, R1
1
(`, R, S)
` ` R ∼ R1
exécution
(`0, R0, S 0)
Code avant
transformation
(`, R1, S)
exécution
0
` ` R 0 ∼ R1
0 , S 0)
(`0, R1
Code après
transformation
109
La recherche en compilation:
quelques perspectives
110
Compiler pour calculer plus vite
La recherche traditionnelle en compilation (augmenter la vitesse
d’exécution de Fortran et C) s’essoufle:
• Énormément de travaux antérieurs.
• Les gains sont de plus en plus faibles pour des efforts de plus
en plus considérables.
• Le modèle de performances des processeurs modernes est
extrêmement complexe.
Il reste quelques niches:
• Langages des haut niveau (Java, Caml, Haskell, . . . ): reste
à gagner le dernier facteur 2.
• Langages exotiques (Esterel, hardware description
languages), langages dédiés (domain specific languages): se
prêtent à des optimisations spectaculaires.
111
Un renouveau de l’analyse statique
Analyser statiquement les programmes non pas pour trouver des
inefficacités à réduire, mais pour trouver des bugs potentiels ou
établir automatiquement des propriétés de sûreté ou de sécurité.
Souvent combiné avec des techniques de vérification
automatique (software model checking).
112
Certifier le compilateur ou l’analyseur statique
Un besoin croissant dans le monde des méthodes formelles.
Hier: certifications de compilateurs de bytecode (non
optimisants), de machines abstraites, de vérificateurs de
bytecode.
Aujourd’hui: beaucoup d’efforts en cours sur la certification
d’analyses statiques et de transformations de programmes.
Demain: vers la certification intégrale de compilateurs natifs
optimisants?
113