-
Notifications
You must be signed in to change notification settings - Fork 8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Backend générique bas-niveau #183
Comments
Pour OCaml, le code assembleur est satisfaisant mais le bytecode ne dépile jamais les valeurs inutilisées, vu qu'il lui faudrait dépiler en bas de la pile… Pour une discipline d'usage des registres bien choisie, les représentations en arbre et en séquences d'un calcul sont équivalentes. On pourrait donc relaxer la représentation en autorisant les assignations de registres à êtres associées à des arbres représentant les expressions à calculer. La fonction transformant un arbre en séquence n'est pas compliquée et pourrait être ajoutée à la structure encapsulant le type du backend bas-niveau. L'important est que la représentation bas-niveau soit figée, donc la plus proche possible de ce qui est traduisible dans le plus grand nombre de langages de programmation. En pratique on sera limité aux langages impératifs, aux langages fonctionnels et aux langages à piles (on oublie Prolog pour l'instant, ainsi que les machines de Turing, les systèmes de tagues et autres bizarreries). Les langages fonctionnels savent gérer les piles très hautes car leurs interpréteurs déterministes sont des machines à piles, mais les langages impératifs sont mauvais dans cet exercice (sauf exception ou paramétrage système généreux); dans ce dernier cas la traduction facilitée sous la forme que séquences d'instructions sur des registres prendrait tout son sens. |
Salut David, merci pour cette analyse qui est très pertinente. Je suis d'accord que si l'on veut faire de la génération de code bas-niveau avec un contrôle très fin sur l'assembleur optimisé alors une représentation intermédiaire comme celle que tu proposes est idéale. Je suis aussi d'accord qu'avoir une représentation intermédiaire figée (appelons là CGIR pour Code Generation Intermediate Representation) permettrait de faire une "separation of concerns" entre la pipeline depuis M/M++ et ce qui est génération de code vers les différents backends est une très bonne idée. Par contre je ne suis pas encore convaincu qu'il faille être aussi bas-niveau que ce que tu proposes sur CGIR. En effet la CGIR que tu proposes est très proche d'une intermediate representation de bas niveau dans un compilateur optimisant, surtout pour la partie instructions à trois opérandes. En effet cette représentation en instructions à trois opérandes me semble être la principale différence entre la CGIR que tu proposes et ce qu'on a déjà dans le backend C DGFiP (où on utilise déjà des registres dans un tableau de variables, le TGV). C'est excellent si l'on cherche la performance mais je suis pas sûr que la performance du code généré soit le seul paramètre que l'on souhaite optimiser ici. Pour le backend C de Mlang, la performance est le paramètre le plus important parce qu'on veut faire des opérations en batch donc on veut in fine les optims débloquée par les instructions à trois opérandes mais GCC ou Clang les font déjà dans leurs pipelines de compilation ; on peut très bien générer du code C avec des right-hand side d'assignation plus compliquée et le compilo C les optimisera (sans doute mieux que nous). Pour ce qui est des autres backends plut haut niveau (Java, Ocaml, etc.), j'ai bien peur que la lisibilité du code généré soit un paramètre important qu'il nous faille respecter. En effet, même si sémantiquement on s'en fout et que le code généré peut être aussi dégeulasse que l'on veut tant qu'il soit sémantiquement correct, dans mon labo de recherche on a observé à de nombreuses reprises que la lisibilité du code généré était très importante pour convaincre les utilisateurs d'utiliser le code généré. Voir à ce propos : https://jonathan.protzenko.fr/2019/01/04/behind-the-scenes.html. C'est d'ailleurs un des requirements qui a influencé le design de BIR tel qu'elle est actuellement. Ce dont j'ai peur c'est qu'en introduisant les instructions à trois opérandes et la floppée de variables intermédiaires que ça entraîne ça ne dégrade trop la lisibilité du code, donc que les potentiels utilisateurs n'arrivent pas à relier le code généré au M/M++ source et qu'on ait du mal à faire adopter Mlang à cause de ça. Pour écarter ce risque, le mieux c'est de discuter avec les potentiels utilisateurs de backends de Mlang (en Java, etc.) et leur demander s'ils seraient prêts à accepter du code générer sans regarder ce qu'il y a à l'intérieur. Si ils s'en foutent, alors go :) Sinon faut leur expliquer qu'ils ne peuvent pas avoir un code très performant ET très lisible :) |
Après analyse plus approfondie du bytecode OCaml, je suis revenu dans mon second message sur les problèmes posés par une représentation trop proche d'un assembleur RISC, en particulier concernant les cibles de type machine à piles (genre machine ZINC). Je proposais de garder les expressions composés sous forme d'arbres. Une fonction qui transformerait les arbres en suite d'instructions sur des registres serait fournie pour ceux qui en aurait l'utilité. On peut toujours généraliser une représentation. On pourrait même passer par des graphes de dépendances au lieu de listes d'instructions au cas où on voudrait exporter du code parallélisable. Mais ça serait inutile car la parallélisme dans la calculette est géré au niveau des contribuables. Vu qu'il y en a des millions, quel serait l'intérêt de faire du multithreading sur une seule instance de calcul ? Autant lancer une calculette sur chaque thread et traiter le plus de contribuables possible. Je ne pense pas qu'il soit raisonnable de faire confiance à l'efficacité des outils tiers. Il y a de bons compilateurs optimisants; il y en a aussi de très mauvais, même sur des plateformes dites «pros». Il serait donc souhaitable - tant que le travail à engager reste raisonnable - de proposer un CGIR le plus optimisé possible qui soit compatible avec les cibles usuelles: impératives et fonctionnelles. Il y a un équilibre à trouver. Mais se dire que le compilateur fera de toute manière le boulot, c'est se défausser d'une responsabilité importante. Autant ne rien optimiser du tout dans ce cas: le compilateur C, Java, etc. s'en chargera… On se méprend beaucoup trop sur le talent des développeurs tiers, dont on ne sait rien a priori. J'ai déjà eu a corriger des bugs critiques dans des librairies standards réputées d'excellente qualité: elles me faisaient planter des applis en production (par exemple, le moteur d'expression régulière de Java a une complexité exponentielle qui fait que des applis peuvent mouliner jusqu'à la mort du système solaire à cause d'un analyseur mal foutu). La DGFiP exploite des tas d'infrastructures antédiluviennes, exotiques, paramétrées de manière opaque, parfois programmées en interne par on ne sait qui on ne sait quand: on ne sait pas ce qu'elles font. Le conseil habituellement donné est d'être souple avec les entrées et rigide avec les sorties, donc produire du code suffisamment efficace sans autre forme de retraitement; si tel ou tel compilateur réussit à optimiser encore un peu plus, et bien tant mieux. Le code généré sera relu ou audité par des gens ayant une expertise assez élevée, le genre qui ne reculera pas devant un peu de complexité. Les programmes en C, même les plus simples, sont déjà illisibles. Si l'expert démissionne devant une fonction calculant des assignations d'expressions algébriques, il il faudra qu'il change de métier. L'excuse de la complexité, on me la donnait déjà pour justifier l'abandon des langages fonctionnels et autres méthodes formelles: typage trop complexe, les gens n'accrocheront pas, ça ne sert à rien… Ce qu'il faut, c'est que le code soit de qualité, donc qu'il soit uniforme, sans bizarrerie, avec un style homogène facile à comprendre quitte à le documenter. Le compirateur en C inclut des commentaires autour du code pour expliquer à quelles instructions elles se réfèrent.De toute manière, relire le code généré ne sert que pendant les phases de conception et de déboguage. Qui relit le code assembleur généré par A priori, on pourrait imaginer un CGIR isomorphe à un langage avec une mémoire globale de type clé-valeur, des procédures non-récursives (n entrées, aucune sortie), des assignations de type variable-expression, des sorties d'erreurs ( |
Ok si je résume ta proposition :
Merci pour tes explications, je suis assez convaincu du bien-fondé de tout ça. Concernant la lisibilité je te fais confiance pour expliquer en interne quand il le faudra les choix qu'on a fait. Du coup les prochaines étapes ce serait :
Tu penses pouvoir te charger ça dans les semaines à venir ? |
Je vais essayer de faire quelque chose. Comme je suis encore en train de découvrir le code et les spécifications, je ne peux pas donner de calendrier précis pour l'instant. J'en saurai plus la semaine prochaine et j'espère avoir un prototype mi-octobre (en étant optimiste; j'ai des obligations les prochaines semaines). Le cahier des charges sera sans doute à amender. J'imagine par exemple qu'il serait utile d'avoir des instructions applicables à toutes les variables de la même classe (saisie, calculée ou base), quelque chose dans ce genre:
Le code généré serait une itération sur les tableaux stockant les variables de ces classes, par exemple en C DGFIP:
L'étude du code des calculs primitifs et correctifs m'en apprendra plus. Ceci dit, ces calculs sont encore en cours de travaux et le résultat final dépendra des choix de conception du langage MPP. |
La représentation abstraite finale du programme à générer (BIR) est complexe et risque d'évoluer en fonction des choix d'architecture et d'optimisation, ce qui nécessiterait de mettre à jour tous les backends spécifiques (C, Java, etc.)
Pour pouvoir coder des backends tout en laissant la liberté de modifier le BIR, il serait envisageable de définir un backend générique bas-niveau qui se calerait sur le moins-disant des backends, une sorte d'assembleur avec uniquement un nombre fini (paramétrable) de registres (doublés, un booléen pour l'existence, un double pour la valeur), les opérations de base (arithmétique, arrondis, etc.), les sous-routines (règles, vérifs, fonctions MPP) et éventuellement un saut conditionnel pour les boucles et autres itérateurs. Il faudrait sans doute également ajouter les informations nécessaires pour générer les bons fichiers et y ranger les bonnes routines.
Ce backend très simple car bas-niveau serait figé afin que les parties en amont (analyse, transformation, optimisation) et en aval (génération de code) soient indépendantes.
En C la traduction serait quasi-immédiate et sans doute très économe en ressources. Pour une cible de plus haut niveau comme OCaml, la question de l'efficacité d'un style de programmation essentiellement impératif se pose. J'ai codé une sorte de prototype représentant un calcul bidon du type qui serait généré, avec 9 tables de variables et trois «registres» r0, r1 et r2:
L'export en bytecode montre que le tas est uniquement utilisé pour stocker les tables de variables. Les calculs exploitent uniquement la pile:
Le même exercice en assembleur X86_64 montre que seuls les registres machines sont utilisés pendant les calculs: le ramasse-miettes n'est jamais appelé. Tout d'abord les fonctions
f
etg
sont des suites d'instructions simples:Ensuite les séquences d'appels aux fonctions
f
etg
débutent par le chargement sur la pile de l'adresse de tous les tableaux utilisés, puis sont appelés l'une après l'autre:L'optimisation des programmes en style impératif lapidaire est donc excellente. Dans un autre langage de haut niveau (Haskell, Lisp, etc.), vraisemblablement, seule la pile serait exploitée sans qu'il y ait besoin d'activer un ramasse-miettes pendant les calculs. Le choix de la représentation la moins-disante, donc la plus proche du genre des langages machines qu'on retrouve dans la quasi-totalité des processeurs généralistes, semble être a priori le meilleur choix pour un backend final générique. La programmation des backends spécifiques en serait sans doute simplifiée et il n'y aurait plus qu'un backend commun à gérer en amont. Il faudrait faire en sorte que seule l'extension des opérateurs de base (ajout d'une fonction logarithme par exemple) soit éventuellement nécessaire.
The text was updated successfully, but these errors were encountered: