IFT6232: Compilateur natif pour noyaux computationnels Rapport 2

publicité
IFT6232: Compilateur natif pour noyaux
computationnels
Rapport 2
Paul Khuong
13 novembre 2008
1
Introduction
L’objectif du projet est de déveloper un compilateur pour des codes numériques
générés semi-automatiquement, avec comme architecture cible une machine moderne telle que les x86-64. À cette fin, le langage source est très régulier (presque
tout est une expression), et permet de spécifier une forme restreinte de nondéterminisme (par).
Pour le premier rapport, ont été implémentés :
– l’infrastructure de base permettant la définition des représentations intermédiaires (update-struct.scm)
– transformation du langage source vers une première représentation intermédiaire, un ASA typé (parse-list.scm)
– propagation/vérification de type (types.scm)
– passage à une forme intermédiaire pour analyses, IR1 (to-ir1.scm,
ir1-annotate-deps.scm)
Lors du dévelopement pour ce rapport, IR1 a été modifié pour mieux répondre
aux demandes, en particulier, la sélection est maintenant une expression normale, et le calcul des dépendances est plus fin.
Pour ce deuxième rapport, plusieurs passes d’optimisations ont été implémentées,
de même que la génération de code (minimalement) :
– simplifications de base de l’IR1 (to-ir1.scm)
– propagation des assignations vers lectures lorsque possible
(forward-assignments.scm)
– élimination d’assignations et certaines variables inutiles
(forward-assignments.scm)
– optimisation sur les expressions (calculs constants, réécritures algébriques)
(algebraic-optimisation.scm)
– transformation de séquences par en code explicitement ordonné
(sequentialize-par.scm)
1
– émission de code (codegen.scm)
– allocation de registres & gestion des débordements de registres
2
Structure du compilateur
Le compilateur gère les programmes sous cinq formes : code source, ASA
typé, IR1, pseudo assembleur avec pseudo-registres, et pseudo assembleur avec
registres alloués.
Le code source est transformé en ASA (non-typé) via sexp->node. Cet ASA
est ensuite typé par annotate-node.
IR1 (typé) permet principalement d’effectuer les analyses plus aisément en
transformant l’arbre expression de l’ASA en des séquences explicites d’instructions (impures) et d’expressions (pures). L’ASA est naı̈vement transformé vers
IR1 par ast-to-ir1. Certaines simplifications de base sont ensuite effectuées
par simplify-ir1 pour clarifier l’IR1. Cependant, il est important de noter que
l’IR1 n’est pas encore complet : des expressions égales peuvent évaluer à des valeurs différentes à l’exécution, puisqu’il y existe aussi des instructions impures
(assignations, écriture sur des tableaux). C’est pourquoi annotate-effects annote chaque sous-expression de lecture une liste de dépendances (instructions
qui affectent la valeur de la lecture). Ainsi, on garanti que deux expressions
équivalentes évaluent à la même valeur à l’exécution.
ast-to-ir1 émet du code très simpliste, où chaque expression ne représente
qu’une seule opération. Afin de pallier à ce problème et obtenir de plus grandes
expressions, forward-assignments remplace le plus possible les lectures de variables par la dernière expression assignée à cette variable. On peut alors éliminer
plusieurs assignations inutiles, et même certaines variables complètement inutilisées à l’aide de elimination. Cela expose aussi de plus grandes expressions aux optimisations algébriques, de calculs constants et de canonicalisation
(optimize-all-expressions).
L’IR1 contient des sections par, où l’ordre d’évaluation des instructions est
laissé libre au compilateur. Il est essentiel, avant de compiler pour une machine
réelle, de fixer l’ordre d’évaluation. Une fois les expressions d’indexation simplifiées et canonicalisées, il est souvent possible de comparer les indices dans
les accès aux tableaux. On peut utiliser cette information pour ordonner les
instructions afin d’obtenir des ordonnancements qui donnent lieu à des accès
mémoire en flux linéaire (autant que possible). reorder-all-pars transforme
les sections par en des séquences d’instructions linéaires avec une heuristique
pour atteindre des accès en flux dans les cas communs.
On peut ensuite réoptimiser le résultat de l’ordonnancement explicite (l’exécution étant mieux déterminée, on a accès à plus d’information). Dans le code
actuel, l’IR1 n’est que simplifié et annoté pour les dépendances.
La génération de code se fait en deux étapes. emit-statements transforme
l’IR1 (sans par) en une liste d’instructions de niveau assembleur (à trois re2
gistres), avec des registres typés (valeurs à virgule flottante ou entiers/pointeurs).
Toutefois, des familles homogènes et infinies de registres virtuels sont utilisées.
Par après, reg-alloc alloue les registres architecturaux réels, gérant les débordements lorsque nécessaire.
3
Représentations intermédiaires
Il existe principalement quatre façons de représenter les programmes dans
le compilateur : code source, ASA typé, IR1 (pour les analyses) et pseudo assembleur. Sauf pour le code source, les structures décrivant ces représentations
sont définies dans nodes.scm.
3.1
Code source
Le code source est donné sous forme de s-expressions. Quelques formes
spéciales sont offertes :
(let1 ([var] [valeur]) [corps])
(par [expression]+)
(progn [expression]+)
(set! [var] [valeur])
(aset! [tableau] [index] [valeur])
Les variables introduites par let1 ne peuvent prendre des valeurs que d’un
seul type (int, float, (array int), (array float)). Les expressions dans une
section par sont évaluées dans un ordre arbitraire, mais de façon atomiques, alors
qu’elles sont évaluées dans l’ordre donné pour progn. set ! permet d’assigner
une nouvelle valeur à une variable, sauf pour les variables liées à des tableaux, qui
sont elles immutables. aset ! permet d’assigner une nouvelle valeur à l’élément
indexé dans un tableau.
Quelques opérateurs sont offerts : (aref [tableau] [index]) évalue à la
valeur de l’élément indexé dans le tableau. (select [condition] [alors]
[sinon]) évalue à la valeur de alors si condition est vrai, et sinon sinon.
Les expressions alors et sinon doivent être complètement pures (pas d’écriture
vers variables ou tableaux). (int [float-expr]) convertit une valeur flottante
vers une valeur entière, et (float [int-expr]) inversement. Les expressions
arithmétiques sur *, + sont aussi disponibles (les types des deux arguments
doivent correspondre et être int ou float).
3.2
ASA typé
L’ASA typé a une structure extrêmement similaire à celle du code source,
excepté que les nodes sont annotés avec leurs types, et la portée lexicale résolue.
3
3.3
IR1
L’IR1 est une représentation dont le but est de faciliter les analyses, tant
sur les effets de bord et contrôle que sur les expressions algébriques. Il y a une
séparation des blocs (seq pour séquences d’instructions et par pour les multiensembles d’instructions), instructions (assignation, introduction/élimination de
variables) et expressions. Les expressions sont typées, et les lectures de valeurs
pouvant être affectées par les effets de bord (lecture de variables ou de tableaux)
sont annotées avec des listes d’instructions pouvant en affecter la valeur. Ainsi,
on garantit que les expressions equal ? (et eq ? via hash-consing) évalueront à
des valeurs équivalentes à l’exécution.
Le flot du contrôle est défini de façon triviale avec les noeuds seq-section,
qui représente l’exécution séquentielle d’une liste instructions, et par-section
qui représente l’exécution non-ordonnée d’une liste d’instructions.
Les instructions sont simples : introduction/élimination de variables (create-var,
kill-var), assignation à une variable (assign-var) et assignation à un tableau
(assign-array).
Toutes les valeurs (expressions pures) sont représentées par des noeuds d’expression. Y sont représentés les lectures de constantes, variables et d’éléments
de tableaux (read-constant, read-variable, read-array), et les opérateurs
définis pour le code source.
3.4
(Pseudo-)assembleur
Le pseudo assembleur défini un langage totalement linéaire (une séquence
d’opérations de niveau assembleur), et des familles de registres. Les opérations
de l’IR1 y sont réflétées presque directement (assignation, lecture de constantes
et tableaux, calculs et movements de valeurs). Les registres sont originalement
tirés de familles infinies (registres à flottants ou à entiers/pointeurs), puis de
familles finies pour les registres architecturaux et infinies pour les positions de
débordement sur la pile.
4
Passes de transformation
L’ASA (non-typé) est tout d’abord produit via sexp->node.
4.1
Passes sur ASA
La fonction annotate-node propage l’information de type depuis les constantes et arguments, et vérifie que ceux-ci correspondent aux opérations effectuées.
Le passage à l’IR1 est fait par ast-to-ir1, qui compile chaque noeud
d’opération vers l’assignation d’une expression simple à une variable. Les blocs
4
par sont compilés vers des section par, et toutes les séquences d’instructions
vers des sections seq.
4.2
Passe sur IR1
La fonction simplify-ir1 effectue des simplifications minimales sur l’IR1 :
fusion de sections seq imbriquées, et élimination de singletons par.
Afin d’assurer que les expressions similaires mais pouvant avoir des valeurs
différentes ne sont pas equal ? (ni eq ?), annotate-effects réécrit les expressions en modifiant les lectures de variables/tableaux avec la liste des opérations
pouvant en affecter la valeur. La logique est actuellement très conservative,
mais semble permettre de tester l’équivalence assez efficacement pour effectuer
les optimisations sur IR1.
Ces informations permettent de facilement transférer les assignations directement aux références à la variable assignée, ce qui subsume la propagation de
copie et de constantes (forward-assignments). Il peut sembler indésirable de
dupliquer toutes les expressions communes. Cependant, il semble être plus important de donner aux optimisations sur les expressions accès au plus d’information possible. Puisque les expressions mathématiquement équivalentes sont souvent canonicalisées, il devrait être possible de, par la suite, identifier et éliminer
les sous-expressions communes.
Le transfert des assignations, en plus de donner des arbres d’expression plus
grands, rend aussi plusieurs assignations (et variables) évidemment inutiles.
elide-assignments élimine ces dernières.
4.2.1
Réécriture des expressions
Une fois qu’on a obtenu des arbres d’expression de taille utile (au lieu d’indiriger à travers des variables en tout moment), il devient utile d’effectuer
des réécritures sur les expressions elles-mêmes (algebraic-optimisation.scm,
optimize-all-expressions).
Les calculs constants sont pliés sur toutes les opérations.
Sur les multiplications et additions, la commutativité et associativité sont
utilisées afin de canonicaliser les calculs équivalents à une forme unique
(rewrite-binary-assoc-comm). De plus, la distributivité est utilisée sur les
multiplications et additions binaires, afin d’arriver à des sommes de produits,
et les additions de sommandes dupliquées transformées en addition de multiplication par une constante
(rewrite-array-index, rewrite-add-array-index, rewrite-mul-array-index,
fold-eq-addend). Cette forme permet de facilement comparer les indices d’accès
dans les tableaux, et, a moyen terme, d’émettre des adresses style x86 simplement. Le hash-consing est particulièrement utile dans cette phase, puisque
presque toutes les opérations sont effectuées jusqu’à l’atteinte d’un point fixe.
5
De plus, cela permet de facilement reconaı̂tre les réécritures circulaires pour
alors n’effectuer aucune transformation (au lieu de réécrire à l’infini).
4.2.2
Ordonnancement des sections par
Le but de cette passe est de donner un ordre explicite à toutes les sections par afin d’avoir, autant que possible, des accès à la mémoire en flux
(sequentialize-par.scm). Il sera aussi utile d’avoir des ordonnancements qui
peut facilement se réduire à des boucles (imbriquées), puisque cela permettrait
de réduire la pression sur la cache d’instructions.
La partie clé de cette passe est la comparaison d’index sous forme symbolique
(les accès à des tableaux différents sont simplement considérés incomparables).
La canonicalisation à des sommes de produits est alors extrêmement utile. En
effet, les indices sontP
transformés à des sommes de multiplication par constante
(par 1 par défaut),
ki xi . Cela permet alors de comparer les sommes ayant
exactement le même ensemble de parties variables (xi ) dans les multiplications.
Si une somme a, ∀xi un |ki | < à celui de l’autre pour la même partie variable,
elle est considérée plus petite. S’il y a ambiguité, les sommes sont incomparables.
En cas d’égalité, la partie constante de l’addition est utilisée pour départager.
Cela permet de traiter correctement les idiomes de base comme indexation en
2 dimensions dans un vecteur (x + y ∗ stride + . . ., où x et y sont connus).
Cette opération est ensuite utilisée afin de générer heuristiquement un ordonnancement des opérations dans un par. La méthode actuelle effectue un tri
topologique sur l’ensemble des accès effectués dans les instructions du par. On
peut ensuite utiliser cet ordre pour ordonner les instructions : on associe à la position de chaque accès l’ensemble des instructions du par qui l’exécute. Lorsqu’il
y en a plus d’un, il suffit de récurser sur cet ensemble plus petit d’instructions
(et forcer un ordre arbitraire s’il n’y a pas de descente).
Il faut ensuite utiliser simplify-ir1 (et annotate-effects) pour revenir à
la forme canonique sur le CFG.
4.2.3
Émission de code
L’émission de code se fait en deux parties. Premièrement, du pseudo-assemblage avec des registres infinis est émis en descendant les arbres d’expressions
naı̈vement (une méthode de programmation dynamique pourra être utilisée plus
tard afin de bien traiter les compromis nombre d’opérations/MAXLIVE).
Il est ensuite simple d’allouer les registres dans le long bloc de base qui en
résulte de façon linéaire et d’utiliser l’heuristique de Belady pour choisir les
valeurs à évacuer vers la pile.
6
5
Exemples
(let1 (y x)
(select (= x (* 1 y))
1
2))
est correctement réécrit en
1
(+ (* 2 (+ 1 x)) (+ (* 4 x) y))
est transformé, via distributivité, associativité et commutativité, en
(+ 2 (+ y (* 6 x)))
Le code correspondant à une multiplication de matrices carrées 2×2 (déroulé),
(par
(aset! dst
(+ 0 (* 0 stride))
(+ (aref dst (+ 0 (* 0 stride)))
(* (aref x (+ 0 (* 0 stride)))
(aset! dst
(+ 0 (* 0 stride))
(+ (aref dst (+ 0 (* 0 stride)))
(* (aref x (+ 0 (* 1 stride)))
(aset! dst
(+ 1 (* 0 stride))
(+ (aref dst (+ 1 (* 0 stride)))
(* (aref x (+ 1 (* 0 stride)))
(aset! dst
(+ 1 (* 0 stride))
(+ (aref dst (+ 1 (* 0 stride)))
(* (aref x (+ 1 (* 1 stride)))
(aset! dst
(+ 0 (* 1 stride))
(+ (aref dst (+ 0 (* 1 stride)))
(* (aref x (+ 0 (* 0 stride)))
(aset! dst
(+ 0 (* 1 stride))
(+ (aref dst (+ 0 (* 1 stride)))
(* (aref x (+ 0 (* 1 stride)))
(aset! dst
7
(aref y (+ 0 (* 0 stride))))))
(aref y (+ 1 (* 0 stride))))))
(aref y (+ 0 (* 0 stride))))))
(aref y (+ 1 (* 0 stride))))))
(aref y (+ 0 (* 1 stride))))))
(aref y (+ 1 (* 1 stride))))))
(+ 1 (* 1 stride))
(+ (aref dst (+ 1 (* 1 stride)))
(* (aref x (+ 1 (* 0 stride))) (aref y (+ 0 (* 1 stride))))))
(aset! dst
(+ 1 (* 1 stride))
(+ (aref dst (+ 1 (* 1 stride)))
(* (aref x (+ 1 (* 1 stride))) (aref y (+ 1 (* 1 stride)))))))
est correctment réordonné pour effectuer des accès contigüs à x (à 0, 1,
stride, stride+1), et croissants sur dst et y.
De plus, l’allocation de registres (xmm et normaux) se fait correctement :
rax <- 0
rbx <- 0
xmm0 <- rcx[rbx]
rdx <- 0
xmm1 <- rbp[rdx]
xmm2 <- xmm1 * xmm0
r8 <- 0
xmm3 <- r9[r8]
xmm4 <- xmm3 + xmm2
r9[rax] <- xmm4
r10 <- 0
r11 <- 1
xmm5 <- rcx[r11]
xmm6 <- rbp[r12]
xmm7 <- xmm6 * xmm5
r13 <- 0
xmm8 <- r9[r13]
xmm9 <- xmm8 + xmm7
r9[r10] <- xmm9
...
Cela se fait également pour l’évaluation du polynome
...
xmm1
xmm0
xmm3
xmm0
xmm3
xmm0
xmm4
xmm0
xmm1
xmm3
<<<<<<<<<<-
xmm4
xmm2
xmm2
xmm2
xmm2
5.
xmm0
xmm4
xmm2
xmm2
+
*
*
*
*
xmm0
xmm2
xmm0
xmm3
xmm0
*
+
*
*
xmm3
xmm1
xmm2
xmm1
8
P15
i=0 (i
+ 1)xi :
xmm1
xmm3
xmm4
xmm1
xmm0
xmm3
xmm0
xmm4
xmm0
xmm1
xmm3
xmm4
xmm1
...
<<<<<<<<<<<<<-
xmm2
4.
xmm3
xmm4
xmm2
xmm2
3.
xmm0
xmm4
xmm2
2.
xmm3
xmm4
* xmm3
*
+
*
*
xmm1
xmm0
xmm2
xmm0
* xmm3
+ xmm1
* xmm2
* xmm1
+ xmm0
Il est malheureusement extrêmement difficile de trouver une expression demandant d’évacuer des valeurs sur la pile : toutes les valeurs sont constamment
recalculées, et aucune élimination de sous-expressions communes n’est effectuée.
6
Revue
Tel que prévu au premier rapport, le travail d’infrastructure a permis de rapidement reprendre le retard qui y avait été pris par rapport à la planification.
L’analyse de vivacité de variables a été partiellement implémentée, mais il reste
encore à faire une réelle analyse itérative qui permettrait d’éliminer plus de variables inutiles. La propagation des constantes et copies et les calculs constants
ont dû être implémentés afin de réordonner les instructions. Similairement,
les simplifications algébriques étaient nécessaire pour le réordonnancement (en
avance sur la planification). Finalement, l’heuristique d’ordonnancement semble
porter fruit (voir l’exemple de multiplication matricielle). L’émission de code
niveau assembleur semble aussi fonctionner, ainsi que l’allocation de registre
(linéaire) et l’évacuation à la Belady (qui n’a toutefois pas été testée).
Pour la prochaine étape, il sera utile d’implémenter une analyse de variables
mortes complète. L’identification des sous-expressions communes est trivialement implémentee ; reste l’étape très importante de les éliminer, et surtout de
décider quand il est préférable de ne pas les éliminer afin de réduire la pression
sur les registres. L’émission de code gagnera aussi grandement à être améliorée
afin de tenir compte de la pression sur les registres (e.g. méthode par programmation dynamique de Aho et Johnson). Il semble très intéressant d’identifier les
boucles (loop rerolling) ; il faudra cependant tenir compte des interactions avec
la propagation des assignations, qui aura tendance à fusionner les instructions
répétées en une seule énorme expressions au niveau de l’IR1. Une optimisation
par “peephole” pourra probablement améliorer sensiblement la qualité du code
émis à peu de frais. La difficulté d’identifier/réordonner les séquences localement
9
parallélisables est difficile à évaluer. Il faudra plus d’exploration pour déterminer
si la passe semble réalisable ou utile.
10
Téléchargement