LLVM en termes de Go

Le développement du compilateur est une tâche très difficile. Mais, heureusement, avec le développement de projets comme LLVM, la solution à ce problème est grandement simplifiée, ce qui permet même à un seul programmeur de créer un nouveau langage proche des performances en C. Travailler avec LLVM est compliqué par le fait que ce système est représenté par une énorme quantité de code, équipé d'une petite documentation. Afin d'essayer de corriger cette faille, l'auteur du matériel que nous publions aujourd'hui va montrer des exemples de code écrit en Go et montrer comment ils sont transmis d'abord à Go SSA puis à LLVM IR en utilisant le compilateur TinyGO . Le code Go SSA et LLVM IR a été légèrement modifié, quelque chose qui n'appartient pas aux explications données ici a été supprimé afin de rendre ces explications plus compréhensibles.

image

Premier exemple


La première fonction que je vais analyser ici est un mécanisme simple pour ajouter des nombres:

func myAdd(a, b int) int{    return a + b } 

Cette fonction est très simple, et, peut-être, rien n'est plus facile à ne pas proposer. Il se traduit par le code Go SSA suivant:

 func myAdd(a int, b int) int: entry:   t0 = a + b                                                    int   return t0 

Avec cette représentation de la fonction, des conseils sur les types de données sont placés à droite, dans la plupart des cas, vous pouvez les ignorer.

Ce petit exemple vous permet déjà de voir l'essentiel d'un aspect de SSA. À savoir, lors de la conversion de code sous forme SSA, chaque expression est divisée en parties les plus élémentaires dont elle se compose. Dans notre cas, la commande return a + b , en fait, représente deux opérations: ajouter deux nombres et retourner le résultat.

De plus, ici vous pouvez voir les blocs de base du programme, dans ce code, il n'y a qu'un seul bloc - l'entrée (bloc d'entrée). Nous parlerons plus en détail des blocs ci-dessous.

Le code Go SSA peut être facilement converti en LLVM IR:

 define i64 @myAdd(i64 %a, i64 %b) { entry: %0 = add i64 %a, %b ret i64 %0 } 

Vous pouvez remarquer que bien que d'autres constructions syntaxiques soient utilisées ici, la structure de la fonction n'a fondamentalement pas changé. Le code IR LLVM est légèrement plus fort que le code Go SSA, similaire à C. Ici, dans la déclaration de fonction, vient d'abord une description du type de données qui lui est retourné, le type de l'argument est indiqué avant le nom de l'argument. De plus, pour simplifier l'analyse infrarouge, les noms des entités globales sont précédés du symbole @ , et avant les noms des entités locales par le caractère % (la fonction est également considérée comme une entité globale).

L'une des caractéristiques de ce code à laquelle vous devez faire attention est que la décision de représenter le type Go int , qui peut être représenté par une valeur 32 bits ou 64 bits, selon le compilateur et le but de la compilation, est prise lors de la création de LLVM Code IR. C'est l'une des nombreuses raisons pour lesquelles le code IR LLVM n'est pas indépendant de la plate-forme. Un tel code créé pour une plate-forme ne peut pas être simplement pris et compilé pour une autre plate-forme (sauf si vous abordez cette tâche avec une extrême prudence ).

Un autre point intéressant à noter est que le type i64 n'est pas un entier signé: il est neutre en termes de représentation du signe du nombre. Selon l'instruction, il peut représenter à la fois des numéros signés et des numéros non signés. Dans le cas de la représentation de l'opération d'addition, cela ne joue aucun rôle, il n'y a donc pas de différence dans le travail avec des nombres avec ou sans signe. Ici, je voudrais noter qu'en C, le débordement d'une variable entière signée conduit à un comportement indéfini, par conséquent, le frontend Clang ajoute le drapeau nsw (pas de retour à la ligne signé) à l'opération, ce qui indique à LLVM qu'il peut partir de l'hypothèse qu'avec il n'y a jamais de débordement.

Cela peut être important pour certaines optimisations. Par exemple, l'ajout de deux valeurs i16 sur une plate-forme 32 bits (avec registres 32 bits) nécessite, une fois l'ajout terminé, que l'opération d'extension de signe reste dans la plage i16 . Pour cette raison, il est souvent plus efficace d'effectuer des opérations entières en tenant compte de la taille de la machine du registre.

Ce qui se passe à l'avenir avec ce code IR n'est pas particulièrement intéressant pour nous maintenant. Le code est optimisé (mais dans le cas d'un exemple aussi simple que le nôtre, rien n'est déjà optimisé), puis il est converti en code machine.

Deuxième exemple


L'exemple suivant, que nous allons examiner, sera un peu plus compliqué. À savoir, nous parlons d'une fonction qui résume la tranche d'entiers:

 func sum(numbers []int) int {   n := 0   for i := 0; i < len(numbers); i++ {       n += numbers[i]   }   return n } 

Ce code est converti en le code Go SSA suivant:

 func sum(numbers []int) int: entry:   jump for.loop for.loop:   t0 = phi [entry: 0:int, for.body: t6] #n                       int   t1 = phi [entry: 0:int, for.body: t7] #i                       int   t2 = len(numbers)                                              int   t3 = t1 < t2                                                  bool   if t3 goto for.body else for.done for.body:   t4 = &numbers[t1]                                             *int   t5 = *t4                                                       int   t6 = t0 + t5                                                   int   t7 = t1 + 1:int                                                int   jump for.loop for.done:   return t0 

Ici, vous pouvez déjà voir plus de constructions spécifiques à la présentation de code sous forme de SSA. La caractéristique la plus évidente de ce code est probablement le fait qu'il n'y a pas de commandes de contrôle de flux structurées. Pour contrôler le flux des calculs, il n'y a que des sauts conditionnels et inconditionnels, et si nous considérons cette commande comme une commande pour contrôler le flux, une commande de retour.

En fait, ici, vous pouvez faire attention au fait que le programme n'est pas divisé en blocs utilisant des accolades (comme dans les langages de la famille C). Il est divisé par des étiquettes, qui ressemble aux langages d'assemblage, et est présenté sous la forme de blocs de base. Dans SSA, les blocs de base sont des séquences contiguës de code commençant par une étiquette et se terminant par des instructions pour terminer le bloc de base, par exemple, return et jump .

Un autre détail intéressant de ce code est l'instruction phi . L'instruction est assez inhabituelle, cela peut prendre un certain temps pour la comprendre. N'oubliez pas que SSA est l'abréviation de Static Single Assignment. Il s'agit d'une représentation intermédiaire du code utilisé par les compilateurs, dans laquelle chaque variable n'est affectée qu'une seule fois. Ceci est idéal pour exprimer des fonctions simples comme notre fonction myAdd illustrée ci-dessus, mais pas pour des fonctions plus complexes comme la fonction sum discutée dans cette section. En particulier, lors de l'exécution de la boucle, les variables i et n changent.

SSA contourne la restriction sur une seule affectation de valeurs variables en utilisant la soi-disant instruction phi (son nom est tiré de l'alphabet grec). Le fait est que pour que la représentation SSA du code puisse être formée pour des langages comme C, vous devez recourir à quelques astuces. Le résultat de l'appel de cette instruction est la valeur actuelle de la variable ( i ou n ), et une liste de blocs de base est utilisée comme paramètre. Par exemple, considérez cette instruction:

 t0 = phi [entry: 0:int, for.body: t6] #n 

Sa signification est la suivante: si le bloc de base précédent était un bloc d' entry (entrée), alors t0 est constant 0 , et si le bloc de base précédent était for.body , alors vous devez prendre la valeur t6 de ce bloc. Tout cela peut sembler assez mystérieux, mais grâce à ce mécanisme, la SSA est assurée. D'un point de vue humain, tout cela complique la compréhension du code, mais le fait que chaque valeur ne soit attribuée qu'une seule fois simplifie grandement de nombreuses optimisations.

Notez que si vous écrivez votre propre compilateur, vous n'avez généralement pas à faire face à de telles choses. Même Clang ne génère pas toutes ces instructions phi , il utilise le mécanisme alloca (il ressemble à travailler avec des variables locales ordinaires). Ensuite, lors de l'exécution d'une passe d'optimisation LLVM, appelée mem2reg , les instructions alloca sont converties au format SSA. TinyGo, cependant, reçoit des données de Go SSA, qui, commodément, a déjà été converti sous forme SSA.

Une autre innovation de ce morceau de code intermédiaire est que l'accès aux éléments de tranche par index se présente sous la forme de l'opération de calcul de l'adresse et de l'opération de déréférencement du pointeur obtenu. Ici, vous pouvez voir l'ajout direct de constantes au code IR (par exemple - 1:int ). Dans l'exemple avec la fonction myAdd cela n'a pas été utilisé. Maintenant que nous avons compris ces fonctionnalités, nous allons voir ce que ce code transformera en le convertissant en forme IR LLVM:

 define i64 @sum(i64* %ptr, i64 %len, i64 %cap) { entry: br label %for.loop for.loop:                                         ; preds = %for.body, %entry %0 = phi i64 [ 0, %entry ], [ %5, %deref.next ] %1 = phi i64 [ 0, %entry ], [ %6, %deref.next ] %2 = icmp slt i64 %1, %len br i1 %2, label %for.body, label %for.done for.body:                                         ; preds = %for.loop %3 = getelementptr i64, i64* %ptr, i64 %1 %4 = load i64, i64* %3 %5 = add i64 %0, %4 %6 = add i64 %1, 1 br label %for.loop for.done:                                         ; preds = %for.loop ret i64 %0 } 

Ici, comme précédemment, nous pouvons voir la même structure, qui comprend d'autres constructions syntaxiques. Par exemple, dans les appels phi , les valeurs et les étiquettes sont échangées. Cependant, il y a aussi quelque chose qui mérite une attention particulière.

Pour commencer, vous pouvez voir ici une signature complètement différente de la fonction. LLVM ne prend pas en charge les tranches, par conséquent, sous forme d'optimisation, le compilateur TinyGo, qui a généré ce code intermédiaire, a divisé la description de cette structure de données en parties. Il pourrait représenter trois éléments de tranche ( ptr , len et cap ) en tant que structure (struct), mais les présenter comme trois entités distinctes permet certaines optimisations. D'autres compilateurs peuvent présenter la tranche d'une autre manière, cela dépend des conventions d'appel des fonctions de la plate-forme cible.

Une autre caractéristique intéressante de ce code est l'utilisation de l' getelementptr (souvent appelée GEP pour faire court).

Cette instruction fonctionne avec des pointeurs et est utilisée pour obtenir un pointeur sur un élément de tranche. Par exemple, comparons-le avec le code suivant écrit en C:

 int* sliceptr(int *ptr, int index) {   return &ptr[index]; } 

Ou avec l'équivalent suivant:

 int* sliceptr(int *ptr, int index) {   return ptr + index; } 

La chose la plus importante ici est que l'instruction getelementptr pas d'opérations de déréférencement. Il calcule uniquement un nouveau pointeur sur la base de celui existant. Il peut être interprété comme mul et add au niveau matériel. Les détails sur les instructions GEP peuvent être trouvés ici .

Une autre caractéristique intéressante de ce code intermédiaire est l'utilisation de l'instruction icmp . Il s'agit d'une instruction générale utilisée pour implémenter des comparaisons entières. Le résultat de cette instruction est toujours une valeur de type i1 - une valeur logique. Dans ce cas, une comparaison est effectuée à l'aide du mot clé slt (signé moins que), car nous comparons deux nombres précédemment représentés par le type int . Si nous comparions deux entiers non signés, nous utiliserions icmp comme instruction et le mot-clé utilisé dans la comparaison serait ult . Pour comparer les nombres à virgule flottante, une autre instruction est utilisée, fcmp , qui fonctionne de manière similaire.

Résumé


Je crois que dans cet article, j'ai examiné les caractéristiques les plus importantes de LLVM IR. Bien sûr, il y a encore beaucoup de choses. En particulier, dans la représentation intermédiaire du code, il peut y avoir de nombreuses annotations qui permettent de prendre en compte certaines fonctionnalités du code connues du compilateur lors des passes d'optimisation qui ne peuvent pas être exprimées d'une autre manière en IR. Par exemple, ce sont l'indicateur inbounds de l'instruction inbounds , ou les nuw nsw et nuw , qui peuvent être ajoutés à l'instruction add . Il en va de même pour le mot-clé private , qui indique à l'optimiseur que la fonction marquée par lui ne sera pas référencée de l'extérieur de l'unité de compilation actuelle. Cela vous permet d'effectuer de nombreuses optimisations interprocédurales intéressantes, telles que l'élimination des arguments inutilisés.

Vous pouvez en savoir plus sur LLVM dans la documentation , à laquelle vous vous référerez souvent lors du développement de votre propre compilateur basé sur LLVM. Voici un guide qui discute du développement du compilateur pour un langage très simple. Ces deux sources d'informations vous seront utiles lors de la création de votre propre compilateur.

Chers lecteurs! Utilisez-vous LLVM?

Source: https://habr.com/ru/post/fr451476/


All Articles