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