Compilation 2022/2023 – Journal du cours

Table des matières

Ce fichier est disponible au format HTML.

1. Cours 01 <2022-09-12 lun.>

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 <2022-09-19 lun.>

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.

https://listes.u-paris.fr/wws/info/m1.2022.compilation.info

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.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 <2022-09-26 lun.>

3.1. Introduction au jalon 1 et au compilateur flap

Voir le code du compilateur Flap, ainsi que l'énoncé du jalon 1.

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 <2022-10-03 lun.>

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 <2022-10-10 lun.>

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 <2022-10-17 lun.>

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 <2022-10-24 lun.>

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 <2022-11-07 lun.>

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.

  1. 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.).

  2. 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

  1. 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 variantes q, l, w et b.

    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 : calcule SRC + DST et stocke le résultat dans DST en mettant à jour rflags.
    • cmpq SRC1, SRC2 : calcule SRC2 - SRC1, ignore le résultat mais met à jour rflags.

    Je réfère aux notes d'Andrew Tolmach pour détails et autres instructions.

  2. 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 <2022-11-14 lun.>

9.1. Programmation x86-64, suite et fin

9.1.1. Les instructions

  • je foo : saute vers foo si le flag ZF de rflags est à 1.
  • call foo : saute vers foo en empilant rip.
  • ret : dépile une valeur et la stocke dans rip.

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.

  1. 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;
    }
    
    1. Solution
      fact:   movq $1, %rax
      fact0:  cmp $1, %rdi
              jle fact1
              imulq %rdi, %rax
              dec %rdi
              jmp fact0
      fact1:  ret
      
  2. Fonction principale

    On souhaite appeler printf() pour afficher le résultat de fact(6). Attention aux contraintes d'alignement de l'ABI System V !

    1. Solution
      msg:    .string "fact(6) = %d\n"
      .global main
      main:   subq $8, %rsp
              movq $6, %rdi
              call factr
              movq $msg, %rdi
              movq %rax, %rsi
              call printf
              movq $0, %rdi
              call exit
      

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 <2022-11-21 lun.>

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.

  1. Points à gérer
    1. 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 ?

      1. Indications

        Prologue :

        pushq %rbp
        movq %rsp, %rbp
        subq $24, %rsp
        

        Épilogue :

        addq $24, %rsp
        popq %rbp
        ret
        

        Disposition de la pile :

        cadre parent  
        arg y  
        arg x  
        saved %rip  
        saved %rbp <- rbp
        var a  
        var b  
        var c <- rsp

        Notons que l'ABI nous laisse le choix de l'ordre des variables locales.

    2. 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;
      
      1. 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 lorsque a 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)
        
    3. 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.

      1. 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
        

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

  1. Similarités
    • Langages de premier ordre avec possibilité de saut indirect.
    • Litéraux identiques.
  2. 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 forme block_e[index_e]. Ces constructions sont traduites vers des appels à write_block() et read_block() dans la syntaxe abstraite, cf. fopixInterpreter.ml.
  3. 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 ?

  1. 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]

  2. 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 <2022-11-28 lun.>

11.1. De Fopix à Retrolix, suite

11.1.1. Les difficultés de la traduction, suite

  1. 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)
    
  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 vers f).

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]
  1. 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
    
    1. 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.

  2. Applications
    let f2 x =
      let f = f0 4 in
      f x
    
    1. Solution
      void f2(int x) {
        block_t *clo = f0(4);
        return clo[0](clo, x);
      }
      

      Notez qu'ici aussi, on a supposé que f0 était une fonction connue, et on l'a implémentée par un appel direct plutôt que par un appel indirect et une fermeture.

  3. 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.

  4. La récursion
    1. 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
      
      1. 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.

      2. 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);
        }
        
    2. 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
      

12. Cours 12 <2022-12-05 lun.>

12.1. De Hobix à Fopix, suite et fin

12.1.1. Schéma général de la traduction

  1. Récursion
    1. 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

      1. 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.

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 :

  1. une expression booléenne qui dit si la valeur discriminée est filtrée,
  2. 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ᵢ)
  1. 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 puis y puis z, 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 ?

  1. On alloue potentiellement beaucoup de fermetures.
  2. 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 attend c.arity - N arguments y1, … yM, etc. et dont le corps appelle c.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.

Auteur: Adrien Guatto

Created: 2022-12-09 ven. 11:22

Validate