Complexité temporelle d'un compilateur

54

Je suis intéressé par la complexité temporelle d'un compilateur. De toute évidence, cette question est très compliquée car de nombreux compilateurs, options de compilateur et variables doivent être pris en compte. Plus précisément, je m'intéresse à LLVM mais je serais intéressé par toutes les idées que les gens ont ou les endroits où commencer des recherches. Un assez google semble apporter peu à la lumière.

Je suppose que certaines étapes d'optimisation sont exponentielles, mais ont peu d'impact sur le temps réel. Par exemple, les exponentielles basées sur le nombre sont les arguments d'une fonction.

D’emblée, je dirais que la génération de l’arbre AST serait linéaire. La génération IR nécessiterait de parcourir l’arbre tout en cherchant des valeurs dans des tableaux toujours croissants, donc ou . La génération de code et la liaison constitueraient un type d'opération similaire. Par conséquent, ma supposition serait , si nous supprimions les exponentielles des variables qui ne croissent pas de manière réaliste.O(n2)O ( n 2 )O(nlogn)O(n2)

Je pourrais être complètement faux cependant. Quelqu'un a-t-il des idées à ce sujet?

superbriggs
la source
7
Vous devez faire attention lorsque vous déclarez que quelque chose est "exponentiel", "linéaire", ou . Au moins pour moi, il n'est pas du tout évident de mesurer votre entrée (Exponentielle dans quoi? Que signifie ?)O ( n log n ) nO(n2)O(nlogn)n
Juho
2
Quand vous dites LLVM, voulez-vous dire Clang? LLVM est un gros projet avec plusieurs sous-projets de compilateur différents, il est donc un peu ambigu.
Nate CK
5
Pour C #, il est au moins exponentiel pour les pires cas (vous pouvez coder le problème NP complet SAT en C #). Il ne s’agit pas uniquement d’une optimisation, elle est également nécessaire pour choisir la surcharge correcte d’une fonction. Pour un langage comme le C ++, ce sera indécidable, car les modèles sont complets.
CodesInChaos
2
@Zane Je ne comprends pas votre point. L'instanciation du modèle se produit pendant la compilation. Vous pouvez coder des problèmes difficiles dans des modèles de manière à obliger le compilateur à résoudre ce problème afin de produire une sortie correcte. Vous pouvez considérer le compilateur comme un interprète du langage de programmation complet.
CodesInChaos
3
La résolution de la surcharge C # est assez délicate lorsque vous combinez plusieurs surcharges avec des expressions lambda. Vous pouvez l'utiliser pour coder une formule booléenne de telle manière que le fait de déterminer s'il existe une surcharge applicable nécessite le problème NPS-complet 3SAT. Pour compiler le problème, le compilateur doit trouver la solution à cette formule, ce qui pourrait même être plus difficile. Eric Lippert en parle en détail dans son billet de blogue Expressions lambda contre méthodes anonymes, cinquième partie
CodesInChaos

Réponses:

50

Le meilleur livre pour répondre à votre question serait probablement: Cooper and Torczon, «Engineering a Compiler», 2003. Si vous avez accès à une bibliothèque universitaire, vous devriez pouvoir en emprunter un exemplaire.

Dans un compilateur de production tel que llvm ou gcc, les concepteurs mettent tout en œuvre pour conserver tous les algorithmes au-dessous de où est la taille de l'entrée. Pour certaines des analyses des phases "d'optimisation", cela signifie que vous devez utiliser des méthodes heuristiques plutôt que de produire un code vraiment optimal.nO(n2)n

Le lexer est une machine à états finis, donc la taille de l'entrée (en caractères) et produit un flux de jetons transmis à l'analyseur.O ( n )O(n)O(n)

Pour de nombreux compilateurs et pour de nombreuses langues, l'analyseur est LALR (1) et traite donc le flux de jetons en temps en nombre de jetons d'entrée. Lors de l'analyse, vous devez généralement garder trace d'une table de symboles, mais, pour de nombreuses langues, elle peut être gérée avec une pile de tables de hachage ("dictionnaires"). Chaque accès au dictionnaire est , mais vous pouvez parfois avoir à marcher dans la pile pour rechercher un symbole. La profondeur de la pile est où est la profondeur d'imbrication des étendues. (Ainsi, dans les langages C-like, combien de couches d'accolades vous êtes à l'intérieur.)O ( 1 ) O ( s ) sO(n)O(1)O(s)s

Ensuite, l'arbre d'analyse est généralement "aplati" dans un graphe de flux de contrôle. Les nœuds du graphe de flux de contrôle peuvent être des instructions à 3 adresses (similaires à un langage d'assemblage RISC), et la taille du graphe de flux de contrôle sera généralement linéaire par rapport à la taille de l'arbre d'analyse.

Ensuite, une série d'étapes d'élimination de la redondance est généralement appliquée (élimination de sous-expression commune, mouvement de code invariant de boucle, propagation constante, ...). (Ceci est souvent appelé "optimisation" bien qu'il y ait rarement quelque chose d'optimal dans le résultat, le but réel est d'améliorer le code autant que possible dans les contraintes de temps et d'espace que nous avons imposées au compilateur.) Chaque étape d'élimination de la redondance exigent généralement des preuves de certains faits concernant le graphe de flux de contrôle. Ces épreuves sont généralement effectuées à l'aide d' une analyse de flux de données . La plupart des analyses de flux de données sont conçues de manière à converger en passes sur le graphe de flux où est (en gros) la profondeur d'imbrication de la boucle et un passage sur le graphe de flux prend du tempsd O ( n ) nO(d)dO(n)où est le nombre d'instructions à 3 adresses.n

Pour des optimisations plus sophistiquées, vous pouvez effectuer des analyses plus sophistiquées. À ce stade, vous commencez à faire des compromis. Vous voulez que vos algorithmes d'analyse prennent beaucoup moins queO(n2)temps dans la taille du graphe de flux du programme entier, mais cela signifie que vous devez vous passer des informations (et des transformations améliorant les programmes) qui pourraient être coûteuses à prouver. L'analyse d'alias est un exemple classique. Dans le cas d'une paire d'écritures en mémoire, vous souhaitez prouver que les deux écritures ne peuvent jamais cibler le même emplacement de mémoire. (Vous voudrez peut-être effectuer une analyse d'alias pour voir si vous pouvez déplacer une instruction au-dessus de l'autre.) Mais pour obtenir des informations précises sur les alias, vous devrez peut-être analyser tous les chemins de contrôle possibles dans le programme, ce qui est exponentiel en nombre de branches. dans le programme (et donc exponentielle dans le nombre de nœuds dans le graphe de flux de contrôle.)

Ensuite, vous entrez dans l'allocation de registre. L'attribution de registre peut être formulée comme un problème de coloration de graphique, et il est connu que la coloration d'un graphique avec un nombre minimal de couleurs est NP-Hard. La plupart des compilateurs utilisent donc une sorte d’heuristique gloutonne combinée à des renversements de registres dans le but de réduire au mieux le nombre de renversements de registres dans des délais raisonnables.

Enfin, vous entrez dans la génération de code. La génération de code consiste généralement en un bloc de base maximal à un moment où un bloc de base est un ensemble de noeuds de graphe de flux de commande connectés de manière linéaire avec une seule entrée et une seule sortie. Cela peut être reformulé en un problème de couverture de graphe où le graphe que vous essayez de couvrir est le graphe de dépendance du jeu d'instructions à 3 adresses du bloc de base et que vous essayez de couvrir avec un jeu de graphes représentant la machine disponible. instructions. Ce problème est exponentiel dans la taille du bloc de base le plus grand (qui peut, en principe, être du même ordre que la taille de l'ensemble du programme). Il s'agit donc encore d'une fois d'une heuristique où seulement un petit sous-ensemble des couvertures possibles est examiné.

Logique errante
la source
4
Tiers! Incidemment, bon nombre des problèmes que les compilateurs tentent de résoudre (par exemple, l'allocation de registres) sont difficiles à résoudre, mais d'autres sont formellement indécidables. Supposons, par exemple, que vous ayez un appel p () suivi d'un appel q (). Si p est une fonction pure, vous pouvez alors réorganiser les appels en toute sécurité tant que p () ne fait pas de boucle infinie. Pour prouver cela, il faut résoudre le problème persistant. Comme avec les problèmes NP-difficiles, un rédacteur de compilateur pourrait faire autant d'efforts que possible pour trouver une solution, autant que faire se peut.
Pseudonyme
4
Oh, encore une chose: Certains systèmes de types utilisés actuellement sont très complexes en théorie. L'inférence de type Hindley-Milner est connue de DEXPTIME-complete et les langages de type ML doivent l'implémenter correctement. Cependant, le temps d'exécution est linéaire dans la pratique car a) les cas pathologiques ne se présentent jamais dans les programmes du monde réel, et b) les programmeurs du monde réel ont tendance à mettre des annotations de type, ne serait-ce que pour obtenir de meilleurs messages d'erreur.
Pseudonyme
1
Excellente réponse, la seule chose qui semble manquer est la partie simple de l'explication, énoncée en termes simples: La compilation d'un programme peut être faite en O (n). Optimiser un programme avant de le compiler, comme le ferait n'importe quel compilateur moderne, est une tâche pratiquement illimitée. Le temps qu’il prend réellement n’est régi par aucune limite inhérente à la tâche, mais bien par la nécessité pratique pour le compilateur de terminer à un moment donné avant que les gens ne se lassent d’attendre. C'est toujours un compromis.
aaaaaaaaaaaa
@Pseudonym, le fait que le compilateur doive souvent résoudre le problème difficile (ou de très vils problèmes difficiles de NP) est l’une des raisons pour lesquelles les normes donnent au rédacteur une marge de manœuvre en assumant qu’un comportement indéfini ne se produit pas (comme des boucles infinies et autres). ).
vonbrand
15

En fait, certains langages (comme C ++, Lisp et D) sont complets à la compilation, donc leur compilation est indécidable en général. Pour C ++, cela est dû à l'instanciation de modèles récursive. Pour Lisp et D, vous pouvez exécuter presque tous les codes au moment de la compilation, vous pouvez donc lancer le compilateur dans une boucle infinie si vous le souhaitez.

Demi
la source
3
Les systèmes de types de Haskell (avec extensions) et de Scala sont également complets de Turing, ce qui signifie que la vérification du type peut prendre un temps infini. Scala a également maintenant des macros Turing-complete.
Jörg W Mittag
5

D'après mon expérience actuelle avec le compilateur C #, je peux dire que pour certains programmes, la taille du binaire de sortie augmente de façon exponentielle par rapport à la taille de la source d'entrée (ceci est en fait requis par la spécification C # et ne peut pas être réduit), donc la complexité temporelle doit aussi être au moins exponentiel.

La tâche générale de résolution de la surcharge en C # est connue pour être NP-difficile (et la complexité de la mise en œuvre réelle est au moins exponentielle).

Un traitement des commentaires de la documentation XML dans les sources C # nécessite également l'évaluation d'expressions XPath 1.0 arbitraires au moment de la compilation, qui est également exponentielle, AFAIK.

Vladimir Reshetnikov
la source
Qu'est-ce qui fait exploser les fichiers binaires C # de cette façon? Cela ressemble à un bug de langue pour moi ...
vonbrand
1
C'est la façon dont les types génériques sont codés dans les métadonnées. class X<A,B,C,D,E> { class Y : X<Y,Y,Y,Y,Y> { Y.Y.Y.Y.Y.Y.Y.Y.Y y; } }
Vladimir Reshetnikov
-2

Mesurez-le avec des bases de code réalistes, telles qu'un ensemble de projets open source. Si vous tracez les résultats sous la forme (codeSize, finishTime), vous pouvez alors tracer ces graphiques. Si vos données f (x) = y sont O (n), le traçage g = f (x) / x devrait vous donner une ligne droite une fois que les données commencent à devenir volumineuses.

Tracer f (x) / x, f (x) / lg (x), f (x) / (x * lg (x)), f (x) / (x * x), etc. Le graphique plongera soit à zéro, augmenter sans limite, ou aplatir. Cette idée est pratique dans des situations telles que la mesure du temps d'insertion à partir d'une base de données vide (par exemple: rechercher une «fuite de performance» sur une longue période).

Rob
la source
1
La mesure empirique des temps d'exécution n'établit pas la complexité des calculs. Premièrement, la complexité de calcul s'exprime le plus souvent en termes de temps d'exécution dans le pire des cas. Deuxièmement, même si vous vouliez mesurer un cas moyen, vous auriez besoin d'établir que vos entrées sont "moyennes" dans ce sens.
David Richerby
Bien sûr, ce n'est qu'une estimation. Mais de simples tests empiriques avec beaucoup de données réelles (chaque commit pour un tas de git repos) pourraient bien battre un modèle prudent. Dans tous les cas, si une fonction est vraiment O (n ^ 3) et que vous tracez f (n) / (n n n n), vous devriez obtenir une ligne bruyante avec une pente approximativement nulle. Si vous ne traçiez que O (n ^ 3) / (n * n), vous le verriez augmenter linéairement. C'est vraiment évident si vous surestimez et regardez la ligne rapidement plonger à zéro.
Rob
1
Θ(nlogn)Θ(n2)Θ(nlogn)Θ(n2)
Je conviens que c'est ce que vous devez savoir si vous craignez un déni de service de la part d'un attaquant, ce qui vous donne une mauvaise entrée, en effectuant une analyse syntaxique des entrées critiques en temps réel. La vraie fonction qui mesure les temps de compilation va être très bruyante, et le cas qui nous tient à cœur va être dans de vrais référentiels de code.
Rob
1
Non. La question concerne la complexité temporelle du problème. Cela est généralement interprété comme étant le temps d'exécution le plus défavorable, ce qui n'est clairement pas le temps d'exécution du code dans les référentiels. Les tests que vous proposez donnent une idée raisonnable de la durée pendant laquelle le compilateur peut s’attendre à un morceau de code donné, ce qui est une bonne et utile information. Mais ils ne vous disent presque rien de la complexité informatique du problème.
David Richerby