Compilation 2022/2023 – Journal du cours
Table des matières
- 1. Cours 01
- 2. Cours 02
- 3. Cours 03
- 4. Cours 04
- 5. Cours 05
- 6. Cours 06
- 7. Cours 07
- 8. Cours 08
- 9. Cours 09
- 10. Cours 10
- 11. Cours 11
- 12. Cours 12
Ce fichier est disponible au format HTML.
1. Cours 01
1.1. Présentation
Bienvenue en Master 1 !
Séance | Enseignant | Horaire | Salle |
---|---|---|---|
Cours | Adrien Guatto | lundi 10h45-12h45 | SG 1003 |
TP | Peter Habermehl | vendredi 8h30-10h30 | SG 2031 |
Attention : les séances de TP commencent la même semaine que les cours !
1.2. Contenu et objectifs du cours
1.2.1. Introduction au cours
En licence, vous avez acquis les bases de l'informatique : appris à programmer dans plusieurs langages, découvert l'algorithmique sous diverses formes, vous êtes familiarisés avec l'utilisation d'un système UNIX, etc.
En master, on ouvre les boîtes noires ! Par exemple, le cours d'architecture des ordinateurs vous initie au fonctionnement interne d'un processeur.
Dans ce cours, on ouvre la boîte noire des langages de programmation : comment fonctionne un compilateur ? Comment passer d'un fichier texte contenant du code source à un programme que votre processeur peut exécuter ?
Comprendre le fonctionnement des compilateurs est l'objectif affiché du cours. Il y en a un deuxième, un peu caché : vous faire programmer, et beaucoup ! Vous allez écrire un compilateur, en OCaml, tout au long du semestre, en binôme, de façon guidée. Vous allez pour ce faire utiliser des méthodes et outils de développement modernes : gestion de version, tests, intégration continue, etc.
1.2.2. Pourquoi étudier la compilation ?
Ce n'est pas une compétence directement mobilisable dans la plupart des emplois de développeur, même si la demande pour des experts en compilation est forte à l'international (exemple : compilation de JavaScript).
Mais comprendre comment fonctionne un compilateur vous transformera en des programmeurs plus mûrs, qui maîtrisent les fondements de leurs outils. C'est une aide concrète lorsqu'on programme, et notamment lorsqu'on débogue et qu'on a besoin de regarder ``sous le capot''.
De plus, la compilation est un sujet pluridisciplinaire :
- architecture des ordinateurs,
- théorie des graphes
- théorie des langages et automates,
- sémantique des langages de programmation,
- génie logiciel,
- méthodes formelles…
Vous allez donc mettre en pratique et revisiter certains concepts que vous avez vu dans d'autres cours, ce qui peut vous aider à les assimiler.
Il va sans dire que la réalisation du projet va aussi beaucoup augmenter votre expérience de la programmation. Ce sera pour la majorité d'entre vous l'occasion de vous confronter pour la première fois à une base de code réaliste !
1.3. Fonctionnement du cours
Vous avez tous reçu une copie de la description du cours (son syllabus, dans le jargon), aussi disponible en ligne sur la page du cours. Prenons le temps de le lire ensemble.
Il se dégage plusieurs principes.
- Les séances de cours sont centrées sur la réalisation du projet, qui oriente les concepts que je vais présenter et nos discussions. Elles se veulent interactives et ouvertes à la discussion.
- Le projet est structuré en grandes étapes indépendantes, les jalons, qui prennent la forme de code à trou : il faudra lire autant qu'écrire !
- Vous aurez les énoncés des jalons rapidement, et chaque séance de cours débutera par 15 à 30 minutes de travail collectif au sujet des questions que vous aurez préparées au sujet des jalons.
- Le bon fonctionnement des jalons sera évalué par une batterie de tests automatiques.
Pour que le cours fonctionne, vos enseignants attendent de vous :
- que vous travailliez de façon continue et régulière tout le semestre,
- que vous rendiez vos jalons à temps (toutes les trois semaines environ),
- que vous prêtiez attention à la qualité du code,
- en cours : que vous preniez des notes tout en réfléchissant et questionnant de façon critique la discussion,
- en TP : que vous posiez des questions et discutiez avec l'enseignant et vos camarades,
- chez vous : que vous lisiez le code du projet ainsi que les documents obligatoires et conseillées, que vous programmiez.
L'évaluation se fera sur votre compréhension du projet, estimée pour 70% par la soutenance, pour 30% par un examen. La soutenance est individuelle, on vous demandera d'expliquer votre code, et la qualité de celui-ci sera prise en compte.
1.4. Introduction à la compilation : le micro-langage Marthe
Le reste de la séance est consacré à la lecture et discussion d'un micro-langage de programmation, Marthe. Voir le fichier marthe.ml.
1.5. À préparer pour le prochain TP et le prochain cours
1.5.1. TODO Prochaine séance de travaux pratiques
- Venir avec son ordinateur portable, vendredi
- S'assurer que celui-ci dispose d'un environnement de développement OCaml fonctionnel (compilateur OCaml, OPAM, dune).
1.5.2. TODO S'inscrire sur la liste de diffusion du cours
1.5.3. TODO Prochain séance de cours
- Finir la gestion des commentaires dans
marthe.ml
.
2. Cours 02
2.1. Message de service
La liste a été configurée correctement, les étudiants et étudiantes peuvent désormais s'inscrire avec leur adresse courriel favorite.
2.2. Le retour de Marthe
Dans marthe.ml, on ajoute les commentaires à l'analyseur lexical, et on lit l'analyseur syntaxique.
2.3. Le cours 2
Voir les transparents.
2.4. À préparer pour le prochain TP et le prochain cours
2.4.1. TODO Lire la documentation des outils ocamllex et Menhir
2.4.2. TODO Préparer le dépôt du projet
- Forker le dépôt Git du projet
https://gaufre.informatique.univ-paris-diderot.fr/aguatto/compilation-m1-2022
- Passer votre fork du dépôt en visibilité privée
- Ajouter l'accès à l'équipe enseignante
- Adrien Guatto @aguatto
- Peter Habermehl @habermeh
- Remplir le fichier AUTEURS du dépôt
3. Cours 03
3.1. Introduction au jalon 1 et au compilateur flap
3.2. La prochaine fois
- On terminera de lire
marthe.ml
. - On commencera une séquence de cours au sujet des langages de programmation.
4. Cours 04
4.1. Suivi du projet
4.1.1. Pourquoi n'y a-t-il pas de if <expr> then <expr>
dans l'AST Hopix ?
Une partie du sucre syntaxique est éliminée lors de l'analyse
syntaxique. C'est le cas du if <expr> then <expr>
qui se désucre de la
même façon que celui d'OCaml.
4.1.2. Comment gérer les positions ?
Menhir ne connaît pas le fichier source, uniquement le lexbuf
. C'est donc
à l'analyseur lexical de remplir ce dernier correctement pour que
l'analyseur syntaxique puisse avoir accès à l'information de position. Voir
le type Lexing.lexbuf
pour cette information de position.
L'analyseur lexical doit essentiellement détecter les nouvelles lignes pour
mettre à jour le lexbuf
. Cf. Lexing.new_line
, déjà utilisée dans le
fichier l'analyseur lexical fourni.
Les règles de l'analyseur syntaxique peut ensuite utiliser les variables
Menhir $startpos
et $endpos
. Dans le fichier fourni, leur utilisation
passera par la règle paramétrique located(X)
.
%inline located(X): x=X { Position.with_poss $startpos $endpos x }
On peut utiliser cette règle pour analyser une valeur de type 'a
located
. Par exemple, le non-terminal located(expression)
produit une
valeur de type 'a located
.
4.1.3. Questions diverses
J'apporte quelques précisions à l'énoncé du jalon 1.
4.2. Introduction à la théorie des langages de programmation
Voir la première partie des transparents dédiés. La substitution (derniers transparents) sera traitée à la prochaine séance.
5. Cours 05
5.1. Suivi du projet
5.1.1. À propos des erreurs
Les erreurs doivent de syntaxe doivent être levées via la fonction
Error.error
, à laquelle il faut passer une position valide.
5.1.2. À propos des positions
Les positions générées dans les messages d'erreur peuvent dépendre de détails de la structure de votre grammaire. Il n'est donc pas gênant que vous n'ayez pas exactement les mêmes messages que ceux présents dans les tests fournis avec le jalon.
5.2. Les réponses aux exercices de code du cours 4
let rec free_vars = let open IdSet in function | EInt i -> empty | EVar x -> singleton x | EPlus (m, n) | EMult (m, n) -> union (free_vars m) (free_vars n) | ESum (x, start, stop, body) -> union (union (free_vars start) (free_vars stop)) (diff (free_vars body) (singleton x)) ;; let fresh_in : IdSet.t -> id = fun s -> let rec loop i = let x = "x" ^ string_of_int i in if IdSet.mem x s then loop (i + 1) else x in loop 0 ;; let rec rename : e -> id -> id -> e = fun m y x -> match m with | EInt _ -> m | EVar z -> if z = x then EVar y else m | EPlus (m, n) -> EPlus (rename m y x, rename m y x) | EMult (m, n) -> EMult (rename m y x, rename n y x) | ESum (z, start, stop, body) -> let start = rename start y x in let stop = rename stop y x in if z = x then ESum (z, start, stop, body) else let k = fresh_in IdSet.(union (free_vars body) (singleton y)) in ESum (k, start, stop, rename (rename stop k z) y x ;; let rec alpha_eq : e -> e -> bool = fun m n -> match m, n with | EInt _, EInt _ | EVar _, EVar _ -> m = n | EPlus (m, n), EPlus (m', n') | EMult (m, n), EMult (m', n') -> alpha_eq m m' && alpha_eq n n' | ESum (x, start, stop, body), ESum (x', start', stop', body') -> alpha_eq start start' && alpha_eq stop stop' && let y = fresh_in (IdSet.union (free_vars body) (free_vars body')) in alpha_eq (rename body y x) (rename body' y x') ;; let subst : e -> id -> e -> e = fun n m x -> match n with | EInt _ -> n | EVar z -> if z = x then m else EVar z | EPlus (n1, n2) -> EPlus (subst n1 m x, subst n2 m x) | EMult (n1, n2) -> EMult (subst n1 m x, subst n2 m x) | ESum (z, start, stop, body) -> let start = subst start m x in let stop = subst stop m x in if z = x then ESum (z, start, stop, body) else let k = fresh_in IdSet.(union (free_vars body) (free_vars m)) in ESum (k, start, stop, subst (rename stop k z) m x ;;
5.3. Introduction à la théorie des langages de programmation
Voir la deuxième partie des transparents dédiés. On a traité la substitution de la première partie.
6. Cours 06
6.1. Introduction au jalon 2
On discute en détail l'énoncé du deuxième jalon, qui consiste en l'écriture d'un inteprète pour Hopix.
6.2. Introduction à la théorie des langages de programmation
Voir la troisième et dernière partie des transparents dédiés.
7. Cours 07
7.1. Jalon 3
On passe la séance à discuter de l'énoncé du jalon 3, à rendre pour dans trois semaines. Le pseudo-code OCaml suivant a été donné.
let rec type_of_expression : typing_environment -> expression -> aty = fun Γ e -> match e with (* Exemple : le cas de l'addition. N.B. : ce cas n'existe pas en Hopix, car l'addition n'y est pas primitive. On vous le donne parce qu'il s'agit sans doute du constructeur d'expression le plus simple à typer qui soit, hormis celui correspondant aux constantes littérales. *) | Add (e₁, e₂) -> (* Γ ⊢ e₁ : int Γ ⊢ e₂ : int ---------------------------- Γ ⊢ e₁ + e₂ : int *) check_type_expression Γ e₁ ATyInt; check_type_expression Γ e₂ ATyInt; ATyInt (* Exemple : le cas de l'application. *) | Apply (e₁, e₂) -> (* Γ ⊢ e₁ : τ₁ → τ₂ Γ ⊢ e₂ : τ₁ ------------------------------- Γ ⊢ e₁ e₂ : τ₂ *) begin match type_of_expression Γ e₁ with | ATyArrow (τ₁, τ₂) -> check_type_expression Γ e₂ τ₁; τ₂ | _ -> erreur () end (* Exemple : le cas de la fonction, qui a besoin d'une annotation. *) | Fun (x, Some τ₁, e) -> (* Γ, x : τ₁ ⊢ e : τ₂ ------------------------- Γ ⊢ fun (x : τ₁) → e : τ₂ *) check_well_formed_type Γ τ₁; let τ₂ = type_of_expression (Env.add x τ₁ Γ) e₂ in ATyArrow (τ₁, τ₂) (* Exemple : le cas de la variable, qui a besoin d'une annotation. *) | Var (x, Some types) -> (* Γ(x) = ∀α₁,...,αₙ.τ Γ ⊢ τ₁ ... Γ ⊢ τₙ -------------------------------------------- Γ ⊢ x^[τ₁;...;τₙ] : τ[α₁\τ₁,...,αₙ\τₙ] *) let Scheme (ts, ty) = Env.lookup Γ x in List.iter check_wellformed_type types; HopixTypes.instantiate (Scheme (ts, ty)) types and check_type_expression Γ e τ = let τ' = type_of_expression Γ e in if not (τ = τ') then erreur ()
8. Cours 08
8.1. Programmation en assembleur x86-64
8.1.1. Contexte : culture générale en architecture des processeurs
On doit faire attention à distinguer architecture et micro-architecture.
L'architecture, ou Instruction Set Architecture est une abstraction permettant la programmation système ou applicative. Exemple : architecture x86-64, architecture ARMv8, architecture RISC-V, etc.
La micro-architecture est une implémentation (ou famille d'implémentations) d'une architecture. Par exemple, la micro-architecture Zen 3 d'AMD pour x86-64, la micro-architecture Vortex/Tempest d'Apple pour ARMv8, la micro-architecture U8 de SiFive pour RISC-V.
Par extension, le terme "micro-architecture" désigne également l'étude des techniques d'implémentation efficaces des processeurs.
Dans ce cours, en tant que spécialistes du logiciel, on s'intéressera à l'architecture plutôt qu'à la micro-architecture.
Deux types d'architectures s'affrontent depuis ~1980 : RISC et CISC.
RISC = Reduced Instruction Set Computer. Offre un petit nombre d'instructions simples et orthogonales, ce qui permet de simplifier la micro-architecture. Exemple : RISC-V, ARM (historiquement).
CISC = Complex Instruction Set Computer. Beaucoup d'instructions baroques et complexes, micro-architecture complexe (décodage). Exemple paradigmatique : x86 (32 bits) et x86-64. Les ARM modernes s'en rapprochent.
On va s'intéresser à x86-64, une architecture à la longue évolution.
8086 (16bits) x86 (32bits) AMD64 (64bits) |—————————————|——————————————|———————————————|—————————————|—————————> 1970 1980 1990 2000 2010
Pourquoi générer du code x86-64 ?
Inconvénients : complexe, baroque, laid.
Avantages : réaliste. Vous permet d'exécuter du code sur votre PC, sans passer par une couche d'émulation (sauf si vous avez un Mac récent). On ne fait pas semblant !
La documentation à laquelle nous pouvons nous référer :
- Les notes d'Andrew Tolmach sur un tout petit sous-ensemble du jeu d'instructions que nous allons utiliser. Leur lecture est obligatoire.
- La documentation combinée d'Intel (5000+ pages), disponible sur la page du constructeur.
8.1.2. L'état du processeur
Les instructions x86-64 servent à modifier l'état du processeur qui, en ce qui nous concerne, est formé des données suivantes.
Attention : deux syntaxes pour le code assembleur x86-64 existent : Intel et GNU/AT&T. Nous utiliserons la syntaxe GNU/AT&T, comme Andrew Tolmach, mais beaucoup de documenation utilise la syntaxe Intel.
- Les registres
Un registre est un petit emplacement mémoire non-adressable situé directement sur le processeur. Y accéder est très rapide.
En x86-64, on dispose de seize registres généraux de 64 bits, baptisés %rax, %rbx, %rcx, %rdx, %rbp, %rsp, %rdi, %rsi, %r8, %r9, %r10, %r11-%r15.
Il y a des registres 32 bits %eax, %ebx, etc. ainsi que 16 bits %ax, %bx, etc. Le contenu de ces petits registres est identique aux bits de poids forts de %rax, %rbx, etc. Autrement dit, ces registres sont des alias, par exemple modifier %ax modifie %eax et modifier %eax modifie %rax.
En plus, on a des registres spécifiques dans lesquels on ne peut pas lire, par exemple %rip le pointeur d'instruction courant, ou %rflags qui contient un champ de bits donnant des informations sur les résultats arithmétiques (génération d'un overflow, etc.).
- La mémoire
Elle est découpée en différentes zones, dont la pile d'exécution.
Les entiers sont représentés en petit-boutien (little-endian), autrement dit les bits de poids forts sont stockés aux adresses les plus basses. Pour plus de détails, consulter Wikipédia.
On lit et écrit dans la mémoire principalement via l'instruction
mov
:mov SRC, DST
.On peut spécifier une adresse mémoire source ou destination via un mode d'adressage complexe. Pour ce qui nous occupe, le mode d'adressage le plus utile sera
OFFSET(BASE, INDEX, SCALE)
où :OFFSET
est une valeur immédiate,BASE
est un registre,INDEX
est un registre optionnel,SCALE
est un entier optionnel pris dans l'ensemble { 1, 2, 4, 8 }.
Quelques exemples :
movq $42, %rax
écrit l'entier 42 dans %rax.movq %rbx, -8(%rsp)
écrit le contenu de %rbx dans la mémoire à l'adresse%rsp - 8
.
Attention : l'instruction
mov
n'autorise pas les transferts de mémoire à mémoire. En d'autres termes, un seul des opérandes peut accéder à la mémoire par instruction. Par exemple,movq (%rax), (%rbx)
est invalide.Il y a plusieurs variantes de l'instruction
mov
, selon la taille des données à transférer :movq
,movl
,movw
,movb
. Ici,q
= quad = 64 bits,l
= long = 32 bits,w
= word = 16 bits,b
= byte = 8 bits.
8.1.3. Les instructions
- Les instructions arithmétiques et logiques
Les instructions arithmétiques et logiques, comme
add
, autorisent aussi les opérandes mémoires. C'est une des différences entre CISC et RISC.Tout comme
mov
, les instructions arithmétiques et logiques sont disponibles en variantesq
,l
,w
etb
.Les instructions arithmétiques peuvent modifier le registre
rflags
, dont le contenu est spécifié par la table suivante.bit signification mnémonique 0 Retenue CF 1 Parité PF 6 Zéro ZF 7 Signe (1 = neg) SF 11 Overflow OF Quelques instructions :
addq SRC, DST
: calculeSRC + DST
et stocke le résultat dansDST
en mettant à jourrflags
.cmpq SRC1, SRC2
: calculeSRC2 - SRC1
, ignore le résultat mais met à jourrflags
.
Je réfère aux notes d'Andrew Tolmach pour détails et autres instructions.
- Instructions de contrôle
Elles permettent de modifier le flot d'exécution (la prochaine instruction à exécuter). En voici quelques-unes :
jmp foo
: saut inconditionnel direct àfoo
.jmp *%rax
: saut inconditionnel indirect à l'adresse contenue dans%rax
.
On verra les instructions restantes lors de la prochaine séance.
9. Cours 09
9.1. Programmation x86-64, suite et fin
9.1.1. Les instructions
je foo
: saute versfoo
si le flag ZF derflags
est à 1.call foo
: saute versfoo
en empilantrip
.ret
: dépile une valeur et la stocke dansrip
.
Attention : à l'exécution d'une instruction de saut, %rsp+8 doit toujours
être aligné sur 16 octets. Donc, %rsp doit être aligné sur 16 octets avant
toute instruction call
, puisque celle-ci va pousser l'adresse de retour.
Pour comprendre les deux dernières instructions, il faut discuter de la pile, ce qu'on va faire tout de suite. Pour les autres, je vous renvoie aux notes d'Andrew Tolmach.
9.1.2. Convention d'appel et gestion de la pile
Une architecture doit spécifier une convention d'appel, qui dicte le fonctionnement des appels de fonction dans le but de permettre l'interopérabilité entre différents compilateurs, voire entre différents langages.
Nous allons utiliser la convention d'appel dictée par l'interface binaire (application binary interface, ou ABI) POSIX System V pour x86-64.
Celle-ci spécifie l'usage d'une pile pour stocker certains arguments de fonction, ainsi que les variables locales. Le sommet courant de la pile (son adresse) est, par convention, stocké dans le registre %rsp (register stack pointer, logique !). Cette adresse doit toujours être alignée sur huit octets (multiple de huit). La pile croît vers les adresses basses.
Un appel de fonction stocke ses données dans son cadre de pile. Le cadre de pile courant est stocké dans le registre %rbp (register base pointer). Elle ne doit pas accéder au reste de la pile (à l'exception de ses arguments, voire plus bas).
Pour travailler sur la pile, on peut utiliser les instructions push
et
pop
. L'instruction pushq SRC
correspond à la séquence d'instructions
subq $8, %rsp; movq SRC, (%rsp)
, l'instruction popq DST
correspond à
la séquence d'instructions movq -8(%rsp), DST; addq $8, %rsp
.
La valeur de retour de la fonction est stockée dans %rax. Les six premiers arguments sont stockés dans %rdi, %rsi, %rdx, %rcx, %r8, %r9. Tous les autres arguments (au delà du sixième) sont empilés de droite à gauche.
Les registres %rbx, %rbp %r12, %r13, %r14 et %r15 doivent être préservé par l'appelé. Donc, si votre fonction les modifie, elle doit faire en sorte de les restaurer avant de. Les autres registres sont susceptibles d'être modifiés par les appelés arbitrairement, gare donc si vous les utilisez autour d'un appel de fonction !
Pile | (Valeurs de %rbp et %rsp) |
---|---|
… | |
argN | |
argN-1 | |
… | |
arg8 | |
arg7 | |
saved %rip | |
saved %rbp | <- %rbp |
local var1 | |
… | |
local varN | <- %rsp |
Enfin, l'invariant suivant doit toujours être vérifié : lors d'un call
,
%rsp doit être aligné sur 16 octets (autrement dit, sa valeur doit être une
adresse multiple de 16). Donc, comme call
empile %rip, on sait que la
valeur %rsp+8 est alignée sur 16 octets au point d'entrée de toute fonction.
Pour les curieux et curieuses qui voudraient connaître le pourquoi du comment, vous pouvez lire les réponses à cette question sur StackOverflow.
9.1.3. Quelques exemples
On a étudié des rudiments de programmation x86-64 lors de la dernière séance, et lors de la séance de TP. Essayons de mettre en pratique aujourd'hui ensemble.
- Factorielle itérative
On écrit le code de factorielle dans un style itératif, avec une boucle. Le code C, pour se fixer les idées :
int64_t fact(int64_t n) { int64_t res = 1; while (n > 1) { res *= n--; } return res; }
- Fonction principale
On souhaite appeler
printf()
pour afficher le résultat defact(6)
. Attention aux contraintes d'alignement de l'ABI System V !
9.2. De Retrolix à x86-64
9.2.1. Retrolix
Le code relatif à Retrolix est contenu dans
src/retrolix/. Commencer par lire l'AST
présent dans retrolixAST.ml
, puis en cas de question, regarder la
sémantique de référence dictée par l'interprète dans retrolixInterpreter.ml.
Il s'agit d'un langage presque aussi bas niveau que l'assembleur, mais pas tout à fait. Quelques caractéristiques :
- des registres (x86-64) et des variables (locales, globales, paramètres),
- le registre matériel %r15 est réservé (jamais utilisé),
- respecte la convention d'appel en ce qui concerne les registres (registres caller-save vs. callee-save, registre stockant la valeur de retour),
- un jeu d'instruction bas niveau.
Attention : les six premiers arguments sont passés par %rdi, %rsi, %rdx, %rcx, %r8, %r9. Donc les arguments déclarés et passés explicitement en Retrolix sont ceux qui viennent des fonctions sources (Fopix) qui avaient plus de six arguments au départ !
9.2.2. x86-64
Le code est contenu dans src/x86-64/, et l'AST qui nous intéresse est dans x86_64_AST.ml. Pas d'interprète ou parser.
On a vu les points saillants de l'assembleur x86-64 la dernière fois.
Remarque : comme on utilise GCC pour l'assemblage et l'édition de liens, nos programmes assembleurs doivent disposer d'une fonction main().
Attention : l'AST est trop permissif ! Il permet d'écrire du code qui
n'assemble pas, par exemple movq (%rsp), (%rsp)
. Éviter de générer ce
genre de code fait partie de votre travail.
9.2.3. Différences entre Retrolix et x86-64
- des chaînes litérales en Retrolix,
- en Retrolix, pas de fonction main(), le point d'entrée du programme est la séquence des blocs d'initialisation de ses variables globales,
- pas de variables en x86-64,
- jeu d'instructions assez différent : Retrolix est plutôt RISC mais x86-64
est très CISC ; par exemple :
- trois adresses vs. deux adresses,
- modes d'adressage et opérandes mémoires limités en x86-64,
- bizarreries en x86-64, par exemple la division.
10. Cours 10
10.1. De Retrolix à x86-64 (suite)
10.1.1. Traduire Retrolix vers x86-64
Certaines des différences que nous venons de décrire ne sont pas essentielles, et sont donc déjà traitées pour vous (chaînes litérales, génération d'un main, …). On va se concentrer sur deux points :
- la traduction des constructions Retrolix en assembleur x86-64,
- la gestion des variables et de la pile.
La passe de traduction est dans
retrolixToX86_64.ml. Vous
devez remplacer les failwith "Students!"
avec le code approprié.
Il s'agit essentiellement d'implémenter deux modules, InstructionSelector
et FrameManager
. Le premier se charge de la traduction de construction
atomiques de Retrolix en x86-64, le second de la gestion de la pile et des
variables. Le second va naturellement faire appel au premier.
Attention : dans ce jalon, on se concentrera sur la correction du code généré. Produire du code optimisé est un objectif secondaire.
- Points à gérer
- Bases de la gestion de la pile
Considérons la fonction ci-dessous.
def f(x, y) local a, b, c: ... end
En suivant l'ABI System V, à quoi doit ressembler son cadre de pile après l'exécution de son prologue ? Quel est le code du prologue, d'ailleurs ? De l'épilogue ?
- Bases de la traduction
Comment traduire les instructions Retrolix suivantes ?
%rax <- load 42;
%rax <- add %rax, %rbx;
%rax <- add %rbx, %rcx;
%rax <- div %rbx, %rcx;
Comment traduire l'instruction suivante, si a est une variable locale (par exemple la première) ? Le premier paramètre de la fonction Retrolix courante ? Une variable globale ?
a <- load 42;
Dans les instructions ci-dessous, on se place dans le corps d'une fonction dont les variables locales sont a, b et c, déclarées dans cet ordre.
%rax <- add %rax, a;
a <- add a, %rax;
a <- add a, b;
a <- add b, c;
- Indications
Pour les instructions élémentaires :
movq $42, %rax
addq %rbx, %rax
movq %rbx, %rax addq %rcx, %rax
movq %rdx, %r15 movq %rbx, %rax cqto idivq %rcx mocq %r15, %rdx
Traduction de
a <- load 42
lorsquea
est :- la première variable locale dans la pile
movq $42, -8(%rbp)
- un paramètre Retrolix (le premier)
movq $42, 16(%rbp)
- une variable globale, stockée au label
a
movq $42, a
La traduction du reste des exemples :
addq -8(%rbp), %rax
addq %rax, -8(%rbp)
movq -16(%rbp), %r15 addq %r15, -8(%rbp)
movq -24(%rbp), %r15 addq -16(%rbp), %r15 movq %r15, -8(%rbp)
- Indications
- Convention d'appel
Comment traduire les appels de fonction ?
def f() call g(23, %rax, %rbx)
N'oubliez pas qu'il faut aussi traiter les appels de fonction terminaux.
- Indications
f: pushq %rbp movq %rsp, %rbp subq $8, %rsp # sert à aligner la pile sur 16 octets pour le call pushq %rbx # argument 3 pushq %rax # argument 2 pushq $23 # argument 1 call g # appel (la pile est bien alignée sur 16 octets !) addq $32, %rsp # libère les arguments sur la pile movq %rbp, %rsp popq %rbp ret
def f() call g(23, %rax, %rbx) tail
- Indications
- Bases de la gestion de la pile
10.2. De Fopix à Retrolix
10.2.1. Présentation de Fopix
Lecture de l'AST Fopix présent dans le fichier fopixAST.ml.
10.2.2. Fopix et Retrolix, similarités et différences
- Similarités
- Langages de premier ordre avec possibilité de saut indirect.
- Litéraux identiques.
- Différences
- Retrolix a des registres machines.
- Retrolix suit la convention d'appel machine.
- Fopix est un langage à base d'expressions de profondeur arbitraire plutôt que d'instructions au format trois adresses.
- Fopix a des && et des || court-circuits.
- Fopix dispose d'instructions de gestion du flot de contrôle structurées.
- Fopix a des déclarations locales internes aux fonctions, tandis que Retrolix ne dispose que d'un espace de nom pour toute fonction (ou initialiseur de variable globale).
- Plus subtil : Fopix accède à la mémoire à travers des blocs. La syntaxe
concrète des affectations prend la forme
block_e[index_e] := val_e
tandis que les déréférences prennent la formeblock_e[index_e]
. Ces constructions sont traduites vers des appels àwrite_block()
etread_block()
dans la syntaxe abstraite, cf.fopixInterpreter.ml
.
- Aparté : un détail négligé en Retrolix et x86-64
Les programmes que nous allons compiler vont reposer sur un exécutif (runtime), c'est à dire du code d'infrastructure.
En ce qui nous concerne, ce runtime prendra la forme d'un fichier écrit en C et concernera notamment des fonctions utiles à la gestion mémoire.
location_t allocate_block(int64_t size); value_t read_block(location_t block, int64_t index); void write_block(location_t block, int64_t index, value_t v);
Il contient aussi du code d'entrée/sortie, ou de comparaisons de certains types de données (notamment les chaînes de caractères).
Attention : la grande différence entre le tas et la pile, en termes de gestion de la mémoire, est que les données sur la pile sont libérées automatiquement en fonction du flot de contrôle (une fonction libère son cadre de pile quand elle retourne à son appelant). Pour libérer la mémoire du tas, on utilise typiquement un garbage-collector (ou ramasse-miettes, ou glanneur de cellules).
(Flap ne comprend pas de ramasse-miettes actuellement.)
Un ramasse-miettes parcourt la mémoire pour détecter si des blocs de mémoire ne sont plus atteignables. Un bloc est atteignable si l'on peut obtenir son adresse en lisant des pointeurs depuis les registres et la pile. Un bloc qui n'est pas atteignable peut être libéré, puisque notre programme ne pourra plus jamais y accéder.
10.2.3. Quelles sont les difficultés de la traduction de Fopix en Retrolix ?
- Passage des expressions au code à trois adresses ; structures de contrôle
Comment compiler vers Retrolix les expressions Fopix suivantes ?
On supposera que le résultat de chaque expression doit être stocké vers une variable locale baptisée "r" et, bien sûr, utiliser autant de variables locales que nécessaire (elles sont là pour ça).
À ce stade, on ne cherche pas du tout à optimiser le code, mais plutôt à trouver un schéma de compilation mécanique qui soit facile à implémenter.
1 - (3 * 4)
x >= 0
if x = 0 then 0 else y / x
(while (x[0] >= 0) (x[0] := x[0] - 1)); x[0]
- Solutions
Toute instruction Retrolix doit être précédée d'une étiquette, mais on les omet ci-dessous les étiquettes superflues. Tous les
xI
sont des variables locales préalablement déclarées.Premier exemple :
x1 <- copy 1; x2 <- copy 3; x3 <- copy 4; x4 <- mul x2, x3; r <- add x1, x4;
Troisième exemple :
x1 <- copy 1; x2 <- copy x; x3 <- eq x1, x2; jumpif eq x3, 0 -> lE, lT lT: r <- copy 0; jmp lK: lE: x4 <- y; x5 <- x; r <- div x4, x5; lK:
Deuxième exemple :
lT: x1 <- read_block(x, 0); x2 <- copy 0; x3 <- gte x1, x2; jumpif eq x3, 0 -> lK, lB lB: x4 <- read_block(x, 0); x5 <- sub x4, 1; write_block(x, 0, x5); jump lT lK: r <- read_block(x, 0);
Remarque : ces solutions sont volontairement naïves. Un attrait des compilateurs optimisants est de permettre, au moins dans une certaine mesure, de séparer la correction de l'efficicacité. Concrètement, on peut générer du code simple qui sera optimisé par une passe ultérieure. En particulier, le code montré ci-dessus peut être facilement généré par une fonction récursive.
11. Cours 11
11.1. De Fopix à Retrolix, suite
11.1.1. Les difficultés de la traduction, suite
- Passage à la convention d'appel de la machine
Fopix dispose du mécanisme d'appel de fonction usuel des langages de programmation de haut niveau. Celui-ci est indépendant de l'architecture cible. À l'inverse, Retrolix respecte la convention d'appel de x86-64. Une étape de la traduction est donc d'implémenter cette traduction.
Pouvez-vous rappeler la convention d'appel POSIX System-V x86-64 ?
Les six premiers arguments sont passés dans les registres %rdi, %rsi, %rdx, %rcx, %r8 et %r9, les suivants sont passés par la pile de droite à gauche.
(Cette convention ne concerne que les valeurs entières et pas les flottants ni les valeurs occupant plus d'un mot machine. Ces derniers cas ne peuvent pas se produire dans flap.)
Remarquons que la pile n'est pas encore explicite en Retrolix, qui dispose donc d'un mécanisme de passage d'arguments dédié. C'est la passe de Retrolix vers x86-64 qui explicite le passage des arguments, comme vous l'avez vu lors des cours précédents.
Comment traduire les expressions suivantes ?
f()
f(1, 2 + 3)
12 + f(3 * 8)
f(1, 2, 3, y, 5, 6, 40 + 2)
- Solutions
call f()
x1 <- copy 1 x2 <- copy 2 x3 <- copy 3 x4 <- add x2, x3 %rdi <- copy x1 %rsi <- copy x4 call f()
x1 <- copy 1 x2 <- copy 2 x3 <- copy 3 x4 <- copy y x5 <- copy 5 x6 <- copy 6 x7 <- copy 40 x8 <- copy 2 x9 <- add x7, x8 %rdi <- copy x1 %rsi <- copy x2 %rdx <- copy x3 %rcx <- copy x4 %r8 <- copy x5 %r9 <- copy x6 call f(x9)
x1 <- copy 12 x2 <- copy 3 x3 <- copy 8 x4 <- add x2, x3 %rdi <- copy x4 call f() x5 <- copy %rax x6 <- add x1, x5
11.2. De Hobix à Fopix
11.2.1. Présentation de Hobix
Voir hobixAST.ml.
Quelles similarités et différences distinguez-vous entre Hobix et Fopix ?
Les langages sont identiques à quelques exceptions près :
- Fopix ne dispose pas de
read/write/allocate_block()
ad-hoc ; - En Hobix, les fonctions sont des valeurs, et on a une construction
apply
générique (la fonction est une expression quelconque), et surtout une construction de fonction anonyme dans les expressions ; - Les littéraux de Fopix comprennent, en plus de ceux de Hobix, un nom de
fonction
LFun f
(qui doit être compris comme un pointeur versf
).
En d'autres termes, en Hobix les fonctions sont de première classe, comme en OCaml, tandis que Fopix ne dispose que de pointeurs de code, comme en C.
Pour traduire Hobix vers Fopix, il faut donc ramener les constructions de bloc à des appels de fonctions génériques, mais surtout éliminer les fonctions de première classe. On va suivre l'approche vue durant le cours d'introduction à la sémantique, avec une passe dite d'explicitation des fermetures (ou closure conversion en anglais).
11.2.2. L'explication des fermetures
Comment traduire les programmes OCaml suivants en C ? Comme d'habitude, on cherche une traduction mécanique et locale (expression par expression), comme le ferait un compilateur !
On choisit le format suivant pour les fermetures.
pointeur de code | variable libre 0 | variable libre 2 | … |
---|---|---|---|
clo[0] | clo[1] | clo[2] | … |
- Fonctions anonymes
Les exemples ci-dessous visent à vous rappeler qu'une fonction n'est pas qu'un pointeur de code.
let f0 z = let y = z * 2 in fun x -> x + y + z
let f1 y = let g = fun x -> x + y in let h = fun x -> 2 * x in if y > 0 then g else h
- Solutions
Remarque : les solutions écrites en pseudo-C ci-dessous sont données dans un but pédagogique. Les détails sont volontairement simplifiés par rapport à ce une traduction d'OCaml vers C complète.
int f0_anon0(block_t *clo, int x) { return x + clo[1] + clo[2]; } block_t *f0(int z) { block_t *clo = allocate_block(3); int y = z * 2; clo[0] = &f0_anon0; clo[1] = y; clo[2] = z; return clo; }
int f1_anon0(block_t *clo, int x) { return x + clo[1]; } int f1_anon1(block_t *clo, int x) { return 2 * x; } block_t *f1(int y) { block_t *clo = allocate_block(2); if (y > 0) { clo[0] = &f0_anon0; clo[1] = y; } else { clo[0] = &f0_anon1; } return clo; }
Attention : dans le code ci-dessus, on a traité les appels aux fonctions top-level de manière spéciale (on a pas fabriqué de fermeture pour elles puisqu'elles sont nécessairement closes !). C'est une optimisation importante mais que vous ne réaliserez sans doute pas dans le jalon.
- Solutions
- Applications
let f2 x = let f = f0 4 in f x
- Schéma général de la traduction
À quoi ressemble la traduction précédente, si on veut générer du Fopix ?
Le pseudo-code ci-dessous exprime la fonction de compilation C(-) qui prend une expression Hopix e et lui associe une expression Fopix C(e).
C(e₁ e₂) = soit c et x des identifiants frais << val c = C(e₁); val x = C(e₂); call c[0] with (c, x) >> C(fun x₁ ... xₖ → e) = soit { y₁, ..., yₗ } = FV(fun (x₁, ..., xₖ) → e), soit "fresh_name" un nom de fonction frais, l'expression est traduite vers : << val c = allocate_block (l + 1); c[0] := &fresh_name; c[1] := y₁; c[2] := y₂; ... c[l] := yₗ; c >> et on ajoute une fonction top-level : << def fresh_name(clo, x₁, ..., xₖ) = C(e)[y₁ \ clo[1], ..., yₗ \ clo[l]] >>
Il reste cependant une question en suspend : celle de la récursion.
- La récursion
- Simple
Comment traduire l'exemple ci-dessous, par exemple ?
let rec repeat n f = let rec aux n = if n > 0 then (f n; aux (n - 1)) in aux n
- Solution 1
Quand on compile le corps de
aux
, on connaît le nom de la fonction Fopix qui correspond. On peut donc spécialiser la traduction pour remplacer l'appel récursif àaux
par un appel direct à la fonction traduite. - Solution 2
On peut mimer l'interprète du jalon 3 et produire une fermeture cyclique. On voit donc l'occurrence récursive de
aux
comme une variable libre.void repeat_aux(block_t *clo, int n) { if (n > 0) { clo[1][0](clo[1], n); clo[2][0](clo[2], n - 1); } } void repeat(int n, block_t *clo_f) { block_t *clo = allocate_block(3); clo[0] = &repeat_aux; clo[1] = clo_f; clo[2] = clo; clo[0](clo, n); }
- Solution 1
- TODO Mutuelle
À traduire pour la prochaine fois.
let repeat_alt n f = let rec odd k = if k < 0 then () else (f true; even (k - 1)) and even k = if k < 0 then () else (f false; odd (k - 1)) in if k mod 2 = 0 then even k else odd k
- Simple
12. Cours 12
12.1. De Hobix à Fopix, suite et fin
12.1.1. Schéma général de la traduction
- Récursion
- Mutuelle
#+BEGIN_SRC ocaml let repeat_alt n f = let rec odd k = if k < 0 then () else (f true; even (k - 1)) and even k = if k < 0 then () else (f false; odd (k - 1)) in if n mod 2 = 0 then even n else odd n #+END_SRC
- Solution
On peut appliquer les mêmes techniques que pour la récursion simple.
void repeat_alt_odd(block_t *clo, int k) { if (k >= 0) { clo[1][0](clo[1], 1); clo[2][0](clo[2], k - 1); } } // repeat_alt_even est similaire ... void repeat_alt(int n, block_t *clo_f) { block_t *clo_odd = allocate_block(3); block_t *clo_even = allocate_block(3); clo_odd[0] = &repeat_alt_odd; clo_odd[1] = clo_f; clo_odd[2] = clo_even; clo_even[0] = &repeat_alt_even; clo_even[1] = clo_f; clo_even[2] = clo_odd; if (n % 2 == 0) { clo_even[0](clo_even, n); } else { clo_odd[0](clo_odd, n); } }
On peut également suivre OCaml et produire une unique fermeture qui partage les pointeurs de toutes les fonctions mutuellement récursives et l'union des variables libres de ces fonctions.
Quel est l'avantage de cette deuxième solution ? Économiser de l'espace sur l'environnement.
- Solution
- Mutuelle
12.2. De Hopix à Hobix
(Cette étape n'est pas couverte par un jalon.)
Comme pour les passes de traduction précédentes, on va commencer par chercher les similarités et différences entre Hopix et Hobix. Quelles sont-elles ?
La principale est qu'Hopix dispose de données structurées : références,
enregistrements, types sommes, types n-uplets. À l'inverse, Hobix n'a que des
constructions d'allocation/création/modification de blocs. De plus, Hobix n'a
pas de boucle dénombrée (for
) mais seulement une boucle while
. Enfin,
Hobix n'a pas de construction de séquencement (e₁; ...; eₖ
) dédiée.
La traduction doit donc éliminer les boucles for
, mais également les
constructeurs et destructeurs des types structurés.
Type | Constructeur | Destructeur |
---|---|---|
Référence | ref e_init | !e |
$N$-uplet | (e\(_1\), …, e\(_N\)) | match e with … |
Enregistrement | { f1 = e\(_1\); …; fN = e\(_N\); } | e.f\(_i\) (ou match) |
Type somme | K (e1, …, eN) | match e with … |
(Pour les références, ne pas oublier l'assignation, qui n'est ni un constructeur ni un destructeur.)
12.2.1. Traduction des boucles for
À votre avis, vers quelle construction Hobix est-il naturel de traduire les
boucles for
de Hopix ?
La boucle while
, bien sûr !
Proposons une traduction générale. Comme précédemment, on écrira C(-) pour la fonction de traduction, i.e., C(e) sera le traduit de e.
C(for x in (lo to hi) { body }) = << let cpt = allocate_block(1); let h = C(hi); cpt[0] := C(lo); while (cpt[0] <= h) { let x = cpt[0]; C(body); cpt[0] := cpt[0] + 1; } >> où cpt et h sont fraîches.
12.2.2. Traduction du séquencement
C(e₁ ; e₂ ; ... ; eₖ) = << val _ = C(e₁); val _ = C(e₂); ...; C(eₖ) >>
12.2.3. Traduction des types référence
Première question : quelle représentation sous forme d'un bloc ? On peut se contenter d'un bloc de taille 1, qui stocke le contenu de la référence.
Pourquoi ?
Parce que toutes les données qu'on va manipuler occupent exactement un mot. Cela provient du fait qu'on manipule soit des entiers machines, soit des booléens manipulés comme des entiers (c'est une perte de place qui nous simplifie néanmoins la vie), soit des pointeurs (y compris des adresses de bloc). Par exemple, une référence qui contient un n-uplet va être implémentée par un bloc de taille 1 dont l'unique cellule contient l'adresse du bloc qui lui même stocke le contenu du N-uplet.
C(ref e) = << let x = allocate_block(1); x[0] := C(e); x >> où x est frais
C(!e) = << C(e)[0] >>
C(e₁ := e₂) = << C(e₁)[0] := C(e₂) >>
12.2.4. Traduction des types enregistrement
Quelle représentation ? Un bloc de taille N, où N est le nombre de champs de l'enregistrement.
La traduction suppose qu'on a fixé un ordre d'énumération quelconque des champs : f1, f2, … jusqu'à fN. En pratique, on peut prendre l'ordre de déclaration, mais cet ordre n'a pas d'importance.
On va supposer qu'on a une fonction IDX(f) qui associe au champ f un indice. Il peut être calculé lors de la définition du type correspondant.
(Pourquoi ? Parce le code source Hopix n'est pas capable de l'observer.)
C({ f1 = e1; ...; fN = eN; }) = << let x = allocate_block(N) in x[IDX(f1)] := C(e1); ...; x[IDX(fN)] := C(eN); x >> où x est frais
Attention : si l'ordre n'importe pas, dans la traduction ci-dessus, il est important qu'on fasse le calcul de eᵢ avant eᵢ₊₁ pour respecter la sémantique de Hopix !
C(e.f) = C(e)[IDX(f)]
12.2.5. Traduction des n-uplets
Une simple variation sur les enregistrements, à la différence que l'ordre des champs est fixé.
12.2.6. Traduction des types sommes
Supposons qu'on ait le type Hopix suivant (syntaxe OCaml).
type t = | Empty | Leaf of int | Node of t * t
Quelle représentation sous forme de bloc adopter pour ses constructeurs ?
On doit toujours avoir au moins une cellule dans le bloc, pour distinguer le
constructeur dont il s'agit. Celle-ci contient une étiquette (tag), qui
associe un entier unique à chaque constructeur (comme pour la fonction IDX()
des enregistrements, elle peut être calculée à la définition du type). En
plus du tag, on a besoin d'autant d'espace que le constructeur a de
paramètres : 0 pour Empty
, 1 pour Leaf
et 2 pour Node
.
Plus généralement, si le constructeur Kᵢ a kᵢ paramètres, on a besoin d'un bloc de taille 1 + kᵢ pour le représenter.
C(K (e_1, ..., e_kᵢ)) = << let x = allocate_block (1 + kᵢ); x[0] := TAG(K); x[1] := C(e_1); ... x[1 + kᵢ] := C(e_kᵢ); x >> où x est frais
12.2.7. Traduction du filtrage de motifs
La traduction du filtrage de motif est plus subtile que celle des constructions précédentes, puisque le filtrage est une construction très expressive qui permet simultanément de déstructurer une valeur, lier des variables, discriminer entre plusieurs cas, etc. Comment traduire l'exemple suivant ?
def len (l) = case l { | Nil => 0 | Cons (_, xs) => 1 + len (xs) }
def len (l) = if l[0] = 0 then 0 else let xs = l[2] in 1 + len (xs)
On rappelle que le filtrage est de la forme match e { b1 | ... | bN }
où
chaque bᵢ
est une branche, c'est à dire de la forme p ⇒ e
, où p
est
un motif. Pouvez-vous rappeler les formes de motifs possibles ?
Il s'agit des :
- variables,
- attrape-tout (wildcard en anglais),
- annotations de types,
- constantes littérales,
- constructeur de types sommes,
- n-uplets,
- enregistrement,
- motif ET,
- motif OU.
L'approche la plus simple est de traduire le filtrage vers une séquence de conditionnelles et déclarations locales imbriquées.
On peut commencer par éliminer les motifs OU, en les ramenant à des branches supplémentaires dans la construction de filtrage initiale. (Pouvez-vous expliciter cette traduction ?)
Comment formuleriez-vous la traduction d'un motif ? On traduit vers :
- une expression booléenne qui dit si la valeur discriminée est filtrée,
- une liste de liaisons identifiant → expression qui indique, le cas échéant, quelles variables ont été liées.
La fonction de traduction des motifs prend en paramètre le motif p mais également l'expression d dont on veut filtrer le résultat.
C(d, x) = (~true~, [ ~x = d~ ]) C(d, _) = (~true~, [ ]) C(d, p : ann) = C(d, p) C(d, l) = (~d = l~, [ ]) C(d, K(p₁, ..., pₖ) = (~c₁ && ... && cₖ && d[0] = TAG(K)~, b₁ @ ... @ bₖ) où (cᵢ, bᵢ) = C(~d[i]~, pᵢ) C(d, ⟨ p₁, ... , pₖ ⟩) = (~c₁ && ... && cₖ~, b₁ @ ... @ bₖ) où (cᵢ, bᵢ) = C(~d[i-1]~, pᵢ) C(d, { l₁ = p₁; ... ; lₖ = pₖ }) = (~c₁ && ... && cₖ~, b₁ @ ... @ bₖ) où (cᵢ, bᵢ) = C(~d[IDX(lᵢ)]~, pᵢ) en supposant l₁, ..., lₖ dans le bon ordre ! C(d, (p₁ && p₂)) = (~c₁ && c₂~, b₁ @ b₂) où (cᵢ, bᵢ) = C(~d~, pᵢ)
La traduction du filtrage lui même est ensuite facile à formuler.
C(match e { p₁ ⇒ e₁ | ... | pₖ ⇒ eₖ }) = << let x = C(e); if c₁ then (let b₁; C(e₁)) else if c₂ then (let b₂; C(e₂)) ... else if cₖ then (let bₖ; C(eₖ)) else error (* impossible si le filtrage est exhaustif *) >> où (cᵢ, bᵢ) = C(x, pᵢ)
- Optimisation du filtrage
C'est un sujet très important qu'on aura pas vraiment le temps d'aborder en détail. S'il vous intéresse, l'article de Luc Maranget ci-dessous est chaudement recommandé !
http://moscova.inria.fr/~maranget/papers/ml05e-maranget.pdf
Pour vous donner un avant-goût : comment compiler le programme suivant de sorte à minimiser le nombre de tests ?
let f x y z = match x, y, z with | _, F, T -> 1 | F, T, _ -> 2 | _, _, F -> 3 | _, _, T -> 4
Avec la fonction de traduction écrire plus haut, on va générer du code qui teste la même variable plusieurs fois sur le même chemin d'exécution.
if y = F && z = T then 1 else if x = F && y = T then 2 else if z = F then 3 else if z = T then 4 else error
On peut essayer de minimiser le nombre de tests en suivant la règle suivante : une même variable ne peut être testée qu'une seule fois sur un chemin d'exécution fixé.
Avec cette règle, le code généré dépend de l'ordre des tests. On peut par exemple tester
x
puisy
puisz
, ce qui aboutit au code suivant.if x = T then if y = T then if z = T then 4 else 3 else (* y = F *) then if z = T then 1 else 3 else (* x = F *) if y = T then 2 else (* y = F *) if z = T then 1 else (* z = F *) 3
D'autres ordres sont bien meilleurs, et permettent d'économiser les tests. C'est par exemple le cas si on commence en testant
y
.if y = T then if x = T then if z = T then 4 else 3 else 2 else if z = T then 1 else 3
12.3. Optimisations des fermetures
Les informations données précédemment suffisent à implémenter une traduction correcte. En revanche, implémentée sans optimisations supplémentaires, celle-ci est très inefficace. Pourquoi ?
- On alloue potentiellement beaucoup de fermetures.
- On fait beaucoup de sauts indirects.
De nombreuses solutions à ces problèmes ont été proposées au cours des
années, et certaines sont implémentées dans les compilateurs de langages
fonctionnels (notamment ocamlc
).
12.3.1. Représentations des fermetures
À quoi ressemblent les fermetures allouées pour le code suivant ?
let f x y u w = let g () = let h n = let k m = x + y + n in k u + n in h w in g
Avec la représentation "plate" adoptée dans notre traduction :
Fermeture pour… | Pointeur de code | Environnement |
---|---|---|
g | g_fun | E1 = [x, y, u, w] |
h | h_fun | E2 = [x, y, u] |
k | k_fun | E3 = [x, y, n] |
(Pourquoi avoir besoin de x
et y
dans h ?)
On constate qu'il y a beaucoup de redondance. Comment faire mieux ?
Avec du partage ! On appelle ça des fermetures chaînées.
Fermeture pour… | Pointeur de code | Environnement |
---|---|---|
g | g_fun | E1 = [x, y, u, w] |
h | h_fun | E2 = [&E1] |
k | k_fun | E3 = [&E2, n] |
On peut ainsi économiser potentiellement beaucoup d'espace, au prix d'un accès un peu plus coûteux aux valeurs dans l'environnement. On économise aussi sur le temps de création de la fermeture.
Cette technique pose cependant un problème grave, qui est déclenché par exemple dans le code ci-dessous. Pouvez-vous voir lequel ?
let f a b = let g c = let h n = b + c + n in h (List.hd a) in g let rec loop' k n = if n <= 0 then k 0 else let a = some_list_of_size n in let k' = f a in loop' (fun r -> k (k' n r)) (n - 1) let loop = loop' (fun r -> r)
Quelle est la complexité en espace de loop' n
? A priori, linéaire : dans
le corps de loop'
on fabrique une chaîne de n
fermetures. Mais ici, les
listes a
de taille n
ne peuvent pas être libérées avant que loop'
termine, puisqu'elles sont atteignables depuis les fermetures pour h
. La
complexité en espace est donc quadratique !
Shao et Appel ont proposé une solution à ce problème. Elle consiste à introduire des environnements partagés de taille minimale pour factoriser uniquement les variables qui sont vraiment partagées entre fermetures. Lire leur article (URL ci-dessous) si la question vous intéresse.
https://flint.cs.yale.edu/flint/publications/escc.pdf
(Le compilateur OCaml utilise des fermetures à plat.)
12.3.2. Gestion de l'application partielle
Comment va être traduit le code suivant ?
let f x y z = x + y + z let g = fun x -> print_string "hello"; fun y -> print_string " world!\n"; fun z -> x + y + z let h = let r = ref 0 in fun x y z -> let n = !r + y + z in r := x; n let test () = print_int (f 1 2 3); print_int (g 1 2 3); print_int (h 1 2 3)
Si on traduit tous les appels et fonctions anonymes uniformément, on va
faire payer le même coût aux trois appels. En particulier, il va falloir
allouer trois fermetures intermédiaires pour f
et h
. Ce n'est pas
raisonnable dans un compilateur réaliste ; on voudrait réussir à compiler
les appels à f
et h
vers l'instruction call
x86-64, en passant les
quatre arguments (1, 2 et 3 plus la fermeture) dans les registres.
On peut tout d'abord réaliser que f
est une fonction top-level close, sans
environnement. On pourrait donc éviter de créer une fermeture et l'appeler
directement comme fonction connue. Attention : cette optimisation n'est
possible que parce qu'on a accès au corps de f
, ce qui nous permet de
vérifier que celui-ci est directement de la forme fun x y z -> ...
. Cette
optimisation n'est pas possible pour h
.
Une autre solution serait d'inliner f
dans son appelant. L'inlining est
simplement le fait de remplacer un appel de fonction dont le corps est connu
par le corps lui-même. Cela a l'avantage d'éviter un appel intermédiaire, et
surtout d'exposer plus de code aux optimiseurs du compilateur.
Pour optimiser h
, une solution employée par exemple dans le compilateur
OCaml est d'augmenter chaque fermeture avec l'arité de la fonction, et de
considérer une construction d'application n-aire (représentée dans l'AST par
un constructeur comme Eapply of exp * exp list
). Lors de l'exécution de
l'application N-aire e e1 e2 ... eN
, on commence par calculer la fermeture
c
associée à l'expression e
, et par nommer x1 x2 ... N
les résultats
de l'évaluation de e1 e2 ... eN
. Ensuite, on regarde le champ c.arity
:
- si
N = c.arity
: on peut faire un appel àc.fun
en passant tous les arguments, sans créer de fermeture intermédiaire; - si
N < c.arity
: on doit créer une fermeture qui attendc.arity - N
argumentsy1
, …yM
, etc. et dont le corps appellec.fun(c, v1, ..., vN, y1, y2, ..., yM)
; - si
N > c.arity
(on parle parfois de surapplication) : faire les applications successives en créant les fermetures intermédiaires.
Un point important : on peut distinguer statiquement les applications
partielles, à partir de l'information de typage. C'est à dire qu'on sait
toujours si on est dans un cas ou N <= c.arity
(application partielle
potentielle) ou N >= c.arity
(surapplication potentielle). On a donc
besoin d'un seul test et pas deux.