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