Compilation C++ avec LLVM

Construire un compilateur C++ JIT fonctionnel avec LLVM

Emmanuel ROCHE

Traducteur

Thierry Jeanneret

L'objectif de ce tutoriel d'Emmanuel Roche est de vous apprendre à construire un compilateur C++ JIT avec LLVM.

Pour réagir au contenu de ce tutoriel, un espace de dialogue vous est proposé sur le forum. Commentez

- Titre : Compilation C++ avec LLVM

- Auteur : Emmanuel ROCHE

- Traducteur : Thierry Jeanneret

- Parution : 3 juillet 2020

- Licence : Le contenu de cet article est rédigé par Emmanuel Roche et est mis à disposition selon les termes de la Licence Creative Commons Attribution - Partage dans les Mêmes Conditions 3.0 non transposé. Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright ® 2013 Developpez.com.

Voir également :

- Compilation C++ JIT avec LLVM - Partie 4 : dépendance à l’environnement d’exécution C

- Compilation C++ JIT avec LLVM - Partie 3 : Corriger le crash du ModulePassManager

- Compilation C++ avec LLVM et clang - Partie 1 : générer du code C++ dynamiquement depuis Lua

Sommaire

- I. Introduction

- II. Classe de base d’interface JIT

- III. Classe d’implémentation du JIT

- III-A. Fonction auxiliaire CHECK_LLVM

- III-B. Chemins de recherche des en-têtes mis à jour

- III-C. Arguments de la ligne de commande mis à jour pour la création de l’invocation du compilateur

- III-D. Maintenir les références sur le PassBuilder et le gestionnaire de Pass

- IV. Construction du module IR et enregistrement de la fonction

- V. Récupérer les pointeurs de fonction

- VI. Programme de test mis à jour

- VII. Étapes suivantes

- VIII. Remerciements Developpez.com

I - Introduction

Dans mon article précédent sur ce sujet, j’ai décrit comment j’ai essayé d’utiliser LLVM et clang pour exécuter certains tests préliminaires de compilation C++ dynamique. Maintenant, dans ce post, je veux pousser ce concept un peu plus loin et construire un compilateur JIT fonctionnel, que je puisse éventuellement utiliser en production, soit directement, soit depuis du code C++ ou Lua ou d’autres interfaces.

II - Classe de base d’interface JIT

Jusqu’ici, j’ai seulement testé une seule opération de compilation avec un seul appel de fonction (runClang(W:/Projects/NervSeed/temp/test1.cxx)). La première chose que j’ai décidé de faire ensuite a été d’encapsuler la logique de compilation LLVM dans une classe appropriée, que je puisse utiliser pour exécuter « plusieurs étapes de compilation » et de multiples « étapes d’appel de fonction ». Comme cet exemple en pseudocode :

1. JIT* jit = new MyJITClass();

3. jit->loadModule("my_file1.cpp");

4. typedef int(*Func)(int)

5. Func func = (Func)jit->lookup("my_print_number_function")

7. func(42);

9. jit->loadModule("my_file2.cpp");

10. typedef int(*AddFunc)(int,int);

11. AddFunc add = (AdddFunc)jit->lookup("my_add_function");

12. add(3,5);

Donc, avec cet usage en tête, je suis arrivé à la déclaration initiale suivante pour ma classe NervJIT :

1. #include <llvm_common.h>

3. namespace nv

4. {

6. struct NervJITImpl;

8. class NVLLVM_EXPORT NervJIT : public nv::RefObject

9. {

10. private:

11. std::unique_ptr<NervJITImpl> impl;

13. public:

15. NervJIT();

16. ~NervJIT();

18. void loadModuleFromFiles(const FileList& files) {

19. for(auto& f: files) {

20. loadModuleFromFile(f);

21. }

22. }

24. void loadModuleFromFile(const std::string& file);

26. void loadModuleFromBuffer(const std::string& buffer);

28. uint64_t lookup(const std::string& name);

29. };

31. };

Quelques points-clés pour le code ci-dessus :

- cette classe fournit juste le support pour :

1. Compiler un fichier source ou un tampon source et charger le résultat en tant que « module » dans le contexte du « contexte du JIT LLVM » d’où nous pouvons extraire les fonctions compilées et les exécuter dynamiquement. C’est fait avec loadModuleFromFile et loadmoduleFromBuffer, évidemment,

2. Récupérer un pointeur sur une fonction compilée qui puisse ensuite être utilisé comme une fonction normale depuis le process hôte. C’est ce que nous faisons dans la fonction lookup ci-dessus ;

- j’utilise une classe de base nv::RefObject simplement pour fournir un mécanisme de base de « pointeur intelligent intrusif » que j’ai de toujours utilisé (oui, je sais, la plupart des gens utiliseraient std::unique_ptr ou std::shared_ptr en C++ moderne, mais je pense toujours qu’un comptage de références intrusif est parfois une meilleure idée ). De toute façon, ça ne devrait pas être un souci ici ;

- j’ai utilisé l’idiome d’implémentation caché (c’est-à-dire ce membre impl ci-dessus) pour cacher tout ce qui concerne LLVM, les en-têtes sont massifs et complexes, je ne veux donc pas maintenir une dépendance externe à eux ;

- le type FileList est simplement un vecteur de strings (i.e. : std::vector<std::string>), j’ai juste ajouté une méthode utilitaire loadModuleFromFiles pour pouvoir compiler plusieurs fichiers à la fois (mais je doute réellement que ce soit vraiment utile à la fin).

Puis, dans le fichier d’implémentation, ces fonctions seront simplement redirigées vers le membre de NervJITImpl comme suit :

1. NervJIT::NervJIT()

2. {

3. logDEBUG("Creating NervJIT object.");

4. impl = std::make_unique<NervJITImpl>();

5. logDEBUG("Done creating NervJIT object.");

6. }

8. NervJIT::~NervJIT()

9. {

10. logDEBUG("Deleting NervJIT object.");

11. impl.reset();

12. logDEBUG("Deleted NervJIT object.");

13. }

15. void NervJIT::loadModuleFromBuffer(const std::string& buffer)

16. {

17. CHECK(impl, "Invalid NervJIT implementation.");

18. auto& compilerInvocation = impl->compilerInstance->getInvocation();

19. auto& frontEndOptions = compilerInvocation.getFrontendOpts();

20. frontEndOptions.Inputs.clear();

21. std::unique_ptr<MemoryBuffer> buf = llvm::MemoryBuffer::getMemBuffer(llvm::StringRef(buffer.data(), buffer.size()));

22. frontEndOptions.Inputs.push_back(clang::FrontendInputFile(buf.get(), clang::InputKind(clang::Language::CXX)));

24. impl->loadModule();

25. }

27. void NervJIT::loadModuleFromFile(const std::string& file)

28. {

29. CHECK(impl, "Invalid NervJIT implementation.");

30. // We prepare the file list:

31. auto& compilerInvocation = impl->compilerInstance->getInvocation();

32. auto& frontEndOptions = compilerInvocation.getFrontendOpts();

34. frontEndOptions.Inputs.clear();

35. frontEndOptions.Inputs.push_back(clang::FrontendInputFile(llvm::StringRef(file), clang::InputKind(clang::Language::CXX)));

37. impl->loadModule();

38. }

40. uint64_t NervJIT::lookup(const std::string& name)

41. {

42. THROW_IF(!impl, "Invalid NervJIT implementation.");

43. return impl->lookup(name);

44. }

Dans le code précédent, nous voyons que nous avons déjà des interactions avec des objets LLVM dans les fonctions de loadModule : fondamentalement, je récupère l’objet compilerInstance qui fait partie de NervJITImpl, puis je mets à jour sa liste de « fichiers » d’entrée avant d’appeler la véritable fonction de compilation [c’est-à-dire loadModule()]. Ainsi, à la fin, que les entrées proviennent d’un fichier ou d’un tampon en mémoire ne fait aucune différence après que la liste frontale Inputs a été mise à jour.

Vous vous demandez sans doute pourquoi j’ai ajouté la fonction loadModuleFromFiles ! Après tout, nous pourrions juste faire un push_back des différents fichiers dans le vecteur frontEndOptions.Inputs ci-dessus pour obtenir le même résultat plus efficacement… Eh bien, en fait, non. Ça ne semble pas fonctionner : lorsque j’essaye de procéder ainsi, il semble que le compilateur veuille réellement trouver et analyser tous les fichiers fournis en entrée, mais ensuite l’objet Module généré ne contient que les fonctions du dernier fichier de la liste… jusqu’ici, je n’ai vraiment aucune idée du pourquoi, malheureusement.

III - Classe d’implémentation du JIT

Nous abordons maintenant le cœur de l’implémentation du compilateur.

Comme dit plus haut, la compilation réelle est effectuée dans la structure NervJITImpl, qui est déclarée comme suit (gardez juste à l’esprit que nous avons mis cette déclaration directement dans le fichier .cpp, rien n’est donc visible dans l’interface NervJIT) :

1. #define NV_MAX_FUNCTION_NAME_LENGTH 256

3. namespace nv

4. {

6. typedef std::unordered_map<std::string, std::string> FunctionNameMap;

8. struct NervJITImpl {

9. std::unique_ptr<llvm::orc::LLJIT> lljit;

10. clang::IntrusiveRefCntPtr<clang::DiagnosticOptions> diagnosticOptions;

11. std::unique_ptr<clang::TextDiagnosticPrinter> textDiagnosticPrinter;

12. clang::IntrusiveRefCntPtr<clang::DiagnosticIDs> diagIDs;

13. clang::IntrusiveRefCntPtr<clang::DiagnosticsEngine> diagnosticsEngine;

15. std::unique_ptr<llvm::orc::ThreadSafeContext> tsContext;

16. std::unique_ptr<clang::CodeGenAction> action;

17. std::unique_ptr<clang::CompilerInstance> compilerInstance;

19. std::unique_ptr<llvm::PassBuilder> passBuilder;

20. std::unique_ptr<llvm::ModuleAnalysisManager> moduleAnalysisManager;

21. std::unique_ptr<llvm::CGSCCAnalysisManager> cGSCCAnalysisManager;

22. std::unique_ptr<llvm::FunctionAnalysisManager> functionAnalysisManager;

23. std::unique_ptr<llvm::LoopAnalysisManager> loopAnalysisManager;

25. std::unique_ptr<clang::LangOptions> langOptions;

27. llvm::ModulePassManager modulePassManager;

29. FunctionNameMap functionNames;

31. char tmpFuncName[NV_MAX_FUNCTION_NAME_LENGTH];

33. NervJITImpl();

34. ~NervJITImpl();

36. void loadModule();

38. uint64_t lookup(const std::string& name);

39. };

41. }

Comme vous le voyez ci-dessus, cette structure est utilisée pour conserver les références sur tous les objets LLVM utilisés durant la compilation JIT. Nous configurons ces objets durant la construction de notre objet NervJITImpl avec le constructeur suivant :

1. void checkLLVMError(llvm::Error Err)

2. {

3. if(Err) {

4. // logAllUnhandledErrors(std::move(Err), errs(), Banner);

5. THROW_MSG("LLVM error: "<<llvm::toString(std::move(Err)));

6. }

7. }

9. template <typename T> T CHECK_LLVM(llvm::Expected<T> &&E) {

10. checkLLVMError(E.takeError());

11. return std::move(*E);

12. }

14. NervJITImpl::NervJITImpl()

15. {

16. lljit = CHECK_LLVM(LLJITBuilder().setNumCompileThreads(2).create());

18. // ES.getMainJITDylib().setGenerator(

19. //     cantFail(DynamicLibrarySearchGenerator::GetForCurrentProcess(DL)));

21. // cf. https://github.com/tensorflow/mlir/issues/24

22. lljit->getMainJITDylib().addGenerator(cantFail(DynamicLibrarySearchGenerator::GetForCurrentProcess(lljit->getDataLayout().getGlobalPrefix())));

24. diagnosticOptions = new clang::DiagnosticOptions;

26. textDiagnosticPrinter = std::make_unique<clang::TextDiagnosticPrinter>(llvm::outs(), diagnosticOptions.get());

28. // The diagnotic engine should not own the client below (or it could if we release our unique_ptr.)

29. diagnosticsEngine = new clang::DiagnosticsEngine(diagIDs, diagnosticOptions, textDiagnosticPrinter.get(), false);

31. compilerInstance = std::make_unique<clang::CompilerInstance>();

32. auto& compilerInvocation = compilerInstance->getInvocation();

34. std::stringstream ss;

35. ss << "-triple=" << llvm::sys::getDefaultTargetTriple();

37. std::vector<const char*> itemcstrs;

38. std::vector<std::string> itemstrs;

39. itemstrs.push_back(ss.str());

41. // cf. https://clang.llvm.org/docs/MSVCCompatibility.html

42. // cf. https://stackoverflow.com/questions/34531071/clang-cl-on-windows-8-1-compiling-error

43. itemstrs.push_back("-x");

44. itemstrs.push_back("c++");

45. itemstrs.push_back("-fms-extensions");

46. itemstrs.push_back("-fms-compatibility");

47. itemstrs.push_back("-fdelayed-template-parsing");

48. itemstrs.push_back("-fms-compatibility-version=19.00");

49. itemstrs.push_back("-std=c++17");

51. for (unsigned idx = 0; idx < itemstrs.size(); idx++) {

52. // note: if itemstrs is modified after this, itemcstrs will be full

53. // of invalid pointers! Could make copies, but would have to clean up then...

54. itemcstrs.push_back(itemstrs[idx].c_str());

55. }

57. // clang::CompilerInvocation::CreateFromArgs(compilerInvocation, itemcstrs.data(), itemcstrs.data() + itemcstrs.size(), *diagnosticsEngine.release());

58. clang::CompilerInvocation::CreateFromArgs(compilerInvocation, llvm::ArrayRef(itemcstrs.data(), itemcstrs.size()), *diagnosticsEngine.get());

60. // langOptions = std::make_unique<clang::LangOptions>();

61. // langOptions->CPlusPlus = true;

62. // langOptions->CPlusPlus17 = true;

64. // compilerInvocation.setLangDefaults(*langOptions, clang::InputKind(clang::Language::CXX), triple, );

65. auto* languageOptions = compilerInvocation.getLangOpts();

66. auto& preprocessorOptions = compilerInvocation.getPreprocessorOpts();

67. auto& targetOptions = compilerInvocation.getTargetOpts();

69. auto& frontEndOptions = compilerInvocation.getFrontendOpts();

70. frontEndOptions.ShowStats = false;

72. auto& headerSearchOptions = compilerInvocation.getHeaderSearchOpts();

74. headerSearchOptions.Verbose = false;

75. headerSearchOptions.UserEntries.clear();

76. headerSearchOptions.AddPath("D:/Apps/VisualStudio2017_CE/VC/Tools/MSVC/14.16.27023/include", clang::frontend::System, false, false);

78. headerSearchOptions.AddPath("C:/Program Files (x86)/Windows Kits/10/Include/10.0.18362.0/um", clang::frontend::System, false, false);

79. headerSearchOptions.AddPath("C:/Program Files (x86)/Windows Kits/10/Include/10.0.18362.0/shared", clang::frontend::System, false, false);

80. headerSearchOptions.AddPath("C:/Program Files (x86)/Windows Kits/10/Include/10.0.18362.0/ucrt", clang::frontend::System, false, false);

81. headerSearchOptions.AddPath("C:/Program Files (x86)/Windows Kits/10/Include/10.0.18362.0/winrt", clang::frontend::System, false, false);

83. auto& codeGenOptions = compilerInvocation.getCodeGenOpts();

85. targetOptions.Triple = llvm::sys::getDefaultTargetTriple();

86. compilerInstance->createDiagnostics(textDiagnosticPrinter.get(), false);

88. std::unique_ptr<llvm::LLVMContext> context = std::make_unique<llvm::LLVMContext>();

89. tsContext = std::make_unique<llvm::orc::ThreadSafeContext>(std::move(context));

91. action = std::make_unique<clang::EmitLLVMOnlyAction>(tsContext->getContext());

93. // Now we build the optimization passes:

94. logDEBUG("Building pass builder.");

95. passBuilder = std::make_unique<llvm::PassBuilder>();

96. loopAnalysisManager.reset(new llvm::LoopAnalysisManager(codeGenOptions.DebugPassManager));

97. functionAnalysisManager.reset(new llvm::FunctionAnalysisManager(codeGenOptions.DebugPassManager));

98. cGSCCAnalysisManager.reset(new llvm::CGSCCAnalysisManager(codeGenOptions.DebugPassManager));

99. moduleAnalysisManager.reset(new llvm::ModuleAnalysisManager(codeGenOptions.DebugPassManager));

101. logDEBUG("Registering passes.");

102. passBuilder->registerModuleAnalyses(*moduleAnalysisManager);

103. passBuilder->registerCGSCCAnalyses(*cGSCCAnalysisManager);

104. passBuilder->registerFunctionAnalyses(*functionAnalysisManager);

105. passBuilder->registerLoopAnalyses(*loopAnalysisManager);

107. logDEBUG("Cross registering proxies.");

108. passBuilder->crossRegisterProxies(*loopAnalysisManager, *functionAnalysisManager, *cGSCCAnalysisManager, *moduleAnalysisManager);

110. logDEBUG("Creating default pipeline.");

111. modulePassManager = passBuilder->buildPerModuleDefaultPipeline(llvm::PassBuilder::OptimizationLevel::O3);

112. }

Le code précédent est assez similaire au code de test initial que j’ai utilisé dans l'article précédent, mais contient certaines mises à jour importantes que nous discutons ici.

III-A - Fonction auxiliaire CHECK_LLVM

J’ai introduit quelques fonctions pour gérer les erreurs et exceptions de LLVM de manière plus « intégrée » : dans LLVM, beaucoup de fonctions renvoient des valeurs Expected<T> qui peuvent contenir des messages d’erreur, plutôt que… une valeur de type T, évidemment. Ensuite, le framework fournit aussi une classe auxiliaire typique nommée ExitOnError, que vous pouvez utiliser pour encapsuler vos appels à ces fonctions LLVM telles que celle-ci par exemple :

lljit = ExitOnErr(LLJITBuilder().create());

Cependant, cette classe auxiliaire affichera le message d’erreur sur la sortie standard (ou au moins sur ce qui est mappé sur llvm::errs() selon ce que je comprends), puis terminera immédiatement le processus par un appel à exit(exitCode). Je voudrais plutôt gérer l’affichage et la gestion d’erreurs par moi-même et j’utilise mes macros/journaux habituels pour le faire. Et ça semble fonctionner aussi bien jusqu’ici.

III-B - Chemins de recherche des en-têtes mis à jour

Le second grand changement dans le code précédent est la mise à jour des chemins de recherche des en-têtes :

1. auto& headerSearchOptions = compilerInvocation.getHeaderSearchOpts();

3. headerSearchOptions.Verbose = false;

4. headerSearchOptions.UserEntries.clear();

5. headerSearchOptions.AddPath("D:/Apps/VisualStudio2017_CE/VC/Tools/MSVC/14.16.27023/include", clang::frontend::System, false, false);

7. headerSearchOptions.AddPath("C:/Program Files (x86)/Windows Kits/10/Include/10.0.18362.0/um", clang::frontend::System, false, false);

8. headerSearchOptions.AddPath("C:/Program Files (x86)/Windows Kits/10/Include/10.0.18362.0/shared", clang::frontend::System, false, false);

9. headerSearchOptions.AddPath("C:/Program Files (x86)/Windows Kits/10/Include/10.0.18362.0/ucrt", clang::frontend::System, false, false);

10. headerSearchOptions.AddPath("C:/Program Files (x86)/Windows Kits/10/Include/10.0.18362.0/winrt", clang::frontend::System, false, false);

En réalité j’ai d’abord mis à jour mon fichier test2.cxx avec ce contenu :

1. #include <string>

3. int nv_add2(int a, int b)

4. {

5. return (a+b)*2;

6. }

8. int nv_sub2(int a, int b)

9. {

10. return (a-b)*2;

11. }

13. int nv_length(const std::string& input)

14. {

15. return input.size();

16. }

Comme vous pouvez l’imaginer, sans la mise à jour des chemins des en-têtes ci-dessus, le compilateur ne pouvait pas trouver l’inclusion de <string> et produisait donc une erreur. Le fichier <string> fait partie des fichiers d’en-tête de Visual Studio, donc du premier chemin d’inclusion, mais, bien sûr, vous avez ensuite des fichiers inclus récursivement et vous finissez par une dépendance au Windows SDK, que je fournis par les quatre chemins d’inclusion suivants.

Note : coder en dur tous ces chemins n’est clairement pas la bonne manière de faire… je corrigerai cela à l’occasion .

III-C - Arguments de la ligne de commande mis à jour pour la création de l’invocation du compilateur

En même temps que je fournissais un chemin correct pour trouver le fichier <string>, j’ai commencé à recevoir une grande quantité d’erreurs étranges de clang qui essayait de compiler mon fichier (par exemple, des erreurs de syntaxe ou de types non définis… tous inclus dans les en-têtes système, évidemment). Ceci m’a conduit à un autre changement majeur de la liste des arguments de la ligne de commande passée à la fonction auxiliaire que nous utilisons pour configurer l’invocation du compilateur de notre compilerInstance :

1. std::stringstream ss;

2. ss << "-triple=" << llvm::sys::getDefaultTargetTriple();

4. std::vector<const char*> itemcstrs;

5. std::vector<std::string> itemstrs;

6. itemstrs.push_back(ss.str());

8. // cf. https://clang.llvm.org/docs/MSVCCompatibility.html

9. // cf. https://stackoverflow.com/questions/34531071/clang-cl-on-windows-8-1-compiling-error

10. itemstrs.push_back("-x");

11. itemstrs.push_back("c++");

12. itemstrs.push_back("-fms-extensions");

13. itemstrs.push_back("-fms-compatibility");

14. itemstrs.push_back("-fdelayed-template-parsing");

15. itemstrs.push_back("-fms-compatibility-version=19.00");

16. itemstrs.push_back("-std=c++17");

- Tous les indicateurs -fxxx sont requis pour la compatibilité avec « la version Microsoft » des fichiers d’en-tête C++ standards disponibles dans Visual Studio (tels que <string>). De nouveau, ceci n’a de sens que si vous programmez sous Windows avec Visual Studio, mais, d’après ce que j’ai compris, clang peut aussi présenter certaines incompatibilités avec les en-têtes de GCC, ce qui peut exiger certains autres indicateurs sur la ligne de commande pour être corrigé : gardez ceci en tête.

- Peut-être plus surprenant, alors que je tentais de corriger les erreurs de clang, j’ai décidé de spécifier le niveau du langage C++ sur la ligne de commande également (avec les options -std==c++11 / c++14 / c++17) et j’ai obtenu l’erreur suivante :

error: invalid argument '-std=c++17' not allowed with 'C'

Donc, ça signifie que le langage par défaut configuré pour l’exécution de mon compilateur était en réalité C et non C++ et, en fait, en y réfléchissant plus soigneusement, dans mon article précédent, j’aurais pu récupérer les symboles des fonctions que j’ai créées (par exemple, nv_add, nv_sub) en utilisant juste ces noms, car il n’y avait pas de décoration de nom à l’œuvre ! Et ceci, bien que j’aie spécifié clang::InputKind(clang::Language::CXX) comme type pour le fichier d’entrée que je fournissais… Ainsi, il semble que clang compilait joyeusement mes fichiers source C++ comme des sources C, jusqu’à ce que j’inclue l’entête <string>… ne me demandez pas pourquoi !

Après cela, même si j’obtenais toujours d’abord l’erreur ci-dessus (invalid argument '-std=c++17' not allowed with 'C'), les noms de fonction apparurent décorés (comme ?nv_add@@YAHHH@Z) et je ne pus les récupérer aussi facilement qu’auparavant. Je pense donc que c’est là que j’ai commencé à compiler vraiment en C++ pour la première fois (mieux vaut tard que jamais  !).

Puis j’ai voulu me débarrasser de cette erreur de langage par défaut (il valait toujours C), j’ai donc explicitement demandé à clang de compiler du « contenu C++ » par les arguments de ligne de commande “-x c++”, et ça a marché…

Comme on peut le lire dans la ligne de code commentée ci-dessus, j’ai aussi trouvé la fonction setLangDefaults que nous pouvons apparemment appeler pour ajuster la définition du langage par défaut… J’ai pensé l’utiliser, mais j’ai réalisé ensuite qu’il serait encore plus simple de spécifier l’argument directement en ligne de commande (mais je pense que l’option devrait aussi marcher…).

III-D - Maintenir les références sur le PassBuilder et le gestionnaire de Pass

Enfin et surtout, vous noterez que j’alloue maintenant les ressources nécessaires à l’optimisation du module IR (IR = Représentation Intermédiaire) sur le tas et non plus sur la pile :

1. logDEBUG("Building pass builder.");

2. passBuilder = std::make_unique<llvm::PassBuilder>();

3. loopAnalysisManager.reset(new llvm::LoopAnalysisManager(codeGenOptions.DebugPassManager));

4. functionAnalysisManager.reset(new llvm::FunctionAnalysisManager(codeGenOptions.DebugPassManager));

5. cGSCCAnalysisManager.reset(new llvm::CGSCCAnalysisManager(codeGenOptions.DebugPassManager));

6. moduleAnalysisManager.reset(new llvm::ModuleAnalysisManager(codeGenOptions.DebugPassManager));

8. logDEBUG("Registering passes.");

9. passBuilder->registerModuleAnalyses(*moduleAnalysisManager);

10. passBuilder->registerCGSCCAnalyses(*cGSCCAnalysisManager);

11. passBuilder->registerFunctionAnalyses(*functionAnalysisManager);

12. passBuilder->registerLoopAnalyses(*loopAnalysisManager);

Malheureusement, il y a une très bonne (ou plutôt une très mauvaise) raison à cela : une chose à laquelle je n’ai pas été très attentif lors de mes premières expériences, c’est qu’il y avait un crash silencieux dans mon programme de test, juste à la sortie de l’appel à la fonction runClang(). Et finalement je l’ai tracé jusqu’à la destruction de l’XXXAnalysisManager (remplacez XXX par Loop/Function/CGSCC/Module). C’est assez simple : je ne pouvais tout simplement pas trouver un moyen de détruire ces ressources proprement après leur allocation. Cela va donc encore plus loin que de simplement les stocker sur le tas, j'ai aussi délibérément laissé des fuites de mémoire, sans essayer de détruire ces objets lors de la destruction de mon objet NervJITImpl :

1. NervJITImpl::~NervJITImpl()

2. {

3. // Note: the memory for the LLJIT object will leak here because we get a crash when we try to delete it.

4. std::cout << "Releasing undestructible pointers." << std::endl;

5. lljit.release();

7. moduleAnalysisManager.release();

8. cGSCCAnalysisManager.release();

9. functionAnalysisManager.release();

10. loopAnalysisManager.release();

12. std::cout << "Done releasing undestructible pointers." << std::endl;

13. }

Comme indiqué ci-dessus, j’ai observé la même chose à propos du pointeur lljit lui-même : je ne peux pas détruire cet objet sans un crash…???? [« Allo Houston… nous avons un problème… »].

D’abord, j’ai pensé que je pouvais faire quelque chose de vraiment faux dans mon propre code, mais en fait j’ai pu reproduire un crash similaire en construisant l’exemple “HowToUseLLJIT” des sources du LLVM. (Notez encore que je construis avec ma configuration CMake personnalisée, c’est peut-être là que je fais quelque chose de faux…) Ensuite, j’ai aussi essayé de construire LLVM en version 10.0.0 plutôt que la version Git courante (11.0.0git), mais j’ai obtenu le même résultat… Donc, n’étant toujours pas sûr de la source du problème et de la manière de le corriger, je vais laisser le code tel quel pour le moment et je reviendrai sur ce problème plus tard.

C’est tout pour l’allocation des ressources : cela n’arrive qu’une fois (lors de la création de l’objet NervJITImpl) et, à partir de là, j’avais bon espoir d’être capable d’utiliser et réutiliser ces ressources pour compiler plusieurs fichiers source C++ en ajoutant de plus en plus de choses au contexte du JIT. Poursuivons notre voyage avec la fonction de construction du module IR.

IV - Construction du module IR et enregistrement de la fonction

La fonction principale utilisée pour exécuter la compilation C++ de LLVM IR est la suivante :

1. void NervJITImpl::loadModule()

2. {

3. if (!compilerInstance->ExecuteAction(*action))

4. {

5. logERROR("Cannot execute action with compiler instance!");

6. }

8. std::unique_ptr<llvm::Module> module = action->takeModule();

9. if (!module)

10. {

11. logERROR("Cannot retrieve IR module.");

12. }

14. // List the functions in the module (before optimizations):

15. logDEBUG("Module function list: ");

16. int i=0;

17. for(auto& f: *module)

18. {

19. logDEBUG("func"<<i++<<": '"<<f.getName().str()<<"'");

21. // We try to demangle the function name here:

22. // cf. llvm/Demangle/Demangle.h

23. size_t len = NV_MAX_FUNCTION_NAME_LENGTH;

24. int status = 0;

25. char* res = llvm::microsoftDemangle(f.getName().str().c_str(), tmpFuncName, &len, &status, MSDF_NoCallingConvention);

26. if(res) {

27. logDEBUG("Function demangled name is: '"<<res<<"'");

29. // And we map that entry in the function names:

30. functionNames.insert(std::make_pair<std::string, std::string>(res, f.getName().str()));

31. }

32. }

34. // We run the optimizations:

35. modulePassManager.run(*module, *moduleAnalysisManager);

37. auto err = lljit->addIRModule(ThreadSafeModule(std::move(module), *tsContext));

38. checkLLVMError(std::move(err));

39. }

D’abord, comme déjà vu plus haut, gardez à l’esprit que je mets à jour la liste frontEndOptions.Inputs juste avant d’appeler cette fonction loadModule(). Ainsi, le compilateur recevra un nouveau fichier source C++ sur lequel travailler.

Ensuite, nous procédons comme suit (en commençant juste comme dans l’article précédent) :

1. Nous exécutons l’action de compilation ;

2. Puis nous récupérons le Module résultant ;

3. Ensuite, j’ai ajouté quelque chose de nouveau ici : je liste les fonctions compilées dans ce nouveau module et, pour chacune d’elles, je fais correspondre le nom de fonction décoré au « nom de fonction un peu dé-décoré ». Je reviendrai sur ce point un peu plus tard (voyez en dessous) ;

4. Nous continuons avec les passes d’optimisation en exécutant le modulePassManager sur notre module nouvellement généré ;

5. Et nous terminons en ajoutant notre module résultant optimisé à notre JIT (en tant que partie de notre JITDylib principal par défaut).

Nous pourrions techniquement mettre différents modules dans différents JITDylibs, mais je ne suis pas sûr que j’en aurais réellement besoin… à clarifier plus tard .

De nouveau, dans le code ci-dessus j’ai utilisé la fonction microsoftDemangle : cette partie doit être adaptée selon l’environnement de développement.

Et c’est tout : si cet appel de fonction se termine correctement, alors ça veut dire que notre code a été compilé et chargé dans la « JIT dynamic library » (« bibliothèque dynamique JIT »), prêt à être récupéré et utilisé ! Et c’est exactement ce que nous faisons dans la section suivante.

V - Récupérer les pointeurs de fonction

Pour récupérer une fonction compilée, nous nous basons sur le mécanisme de « recherche de symboles » disponible dans l’objet llvm::orc::LLJIT, mais nous devons tenir compte d’un piège : les noms des fonctions C++ sont « décorés » et nous ne pouvons donc pas récupérer une fonction juste en nous basant sur son nom. C’est là qu’entre en jeu notre génération de correspondance de noms dans l’appel à loadModule() ci-dessus ???? :

1. uint64_t NervJITImpl::lookup(const std::string& name)

2. {

3. // First we check if we have registered a mangled name for that function:

4. auto it = functionNames.find(name);

5. std::string fname;

6. if(it != functionNames.end()) {

7. fname = it->second;

8. logDEBUG("Using mangled name: "<<fname);

9. }

10. else {

11. fname = name;

12. }

14. JITEvaluatedSymbol sym = CHECK_LLVM(lljit->lookupLinkerMangled(fname.c_str()));

15. return sym.getAddress();

16. }

En réalité, l’objet llvm::orc::LLJIT bénéficie aussi d’un certain support pour récupérer une fonction à partir de son nom non décoré, en utilisant les fonctions mangle() et mangleAndIntern(). Je les ai juste essayées rapidement et ça n’a pas semblé produire le résultat que j’espérais, c’est pourquoi j’ai construit cette table de correspondance par moi-même, mais je devrais sans doute étudier plus longuement comment utiliser ces fonctionnalités de base…

Avec ce code, nous pouvons par exemple demander :

typedef int(*Func)(int, int);
Func add1 = (Func)jit->lookup("int nv_add(int, int)");

Et ça fonctionnera ici, même si la fonction nv_add est réellement une fonction C++ (c’est-à-dire, elle n’est pas exportée en tant que fonction C ???? donc son « symbole » ne correspond pas simplement à son nom !)

Maintenant, bien sûr, ce type d’usage est quelque peu limité.

- Vous devez toujours être conscient de la « signature de la fonction » et l’entrer exactement, puisque LLVM dé-décorera son nom. Évidemment, j’ai triché ici et j’ai utilisé les sorties de débogage de loadModule() pour trouver ce que doit être le nom d’entrée que je fournis comme entrée test :

[Debug]               func0: '?nv_add@YAHHH@Z'
[Debug]               Function demangled name is: 'int nv_add(int, int)'
[Debug]               func1: '?nv_sub@@YAHHH@Z'
[Debug]               Function demangled name is: 'int nv_sub(int, int)'
[Debug]               Before optimization module function list:
[Debug]               func0: '?nv_add2@@YAHHH@Z'
[Debug]               Function demangled name is: 'int nv_add2(int, int)'
[Debug]               func1: '?nv_sub2@@YAHHH@Z'
[Debug]               Function demangled name is: 'int nv_sub2(int, int)'

Clairement, si vous commencez à utiliser des types même « légèrement plus » complexes, les choses peuvent devenir rapidement, euh, eh bien… beaucoup moins pratiques :

1. [Debug]               func2: '?nv_length@@YAHAEBV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@@Z'

2. [Debug]               Function demangled name is: 'int nv_length(class std::basic_string<char, struct std::char_traits<char>, class std::allocator<char>> const &)'

3. [Debug]               func3: '?size@?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@QEBA_KXZ'

4. [Debug]               Function demangled name is: 'public: unsigned __int64 std::basic_string<char, struct std::char_traits<char>, class std::allocator<char>>::size(void) const'

5. [Debug]               func4: '?createClass@@YAPEAVMyClass@@H@Z'

6. [Debug]               Function demangled name is: 'class MyClass * createClass(int)'

7. [Debug]               func5: '??2@YAPEAX_K@Z'

8. [Debug]               Function demangled name is: 'void * operator new(unsigned __int64)'

9. [Debug]               func6: '??0MyClass@@QEAA@H@Z'

10. [Debug]               Function demangled name is: 'public: MyClass::MyClass(int)'

11. [Debug]               func7: '?showValue@@YAHPEAVMyClass@@@Z'

12. [Debug]               Function demangled name is: 'int showValue(class MyClass *)'

13. [Debug]               func8: '?getValue@MyClass@@QEBAHXZ'

14. [Debug]               Function demangled name is: 'public: int MyClass::getValue(void) const'

15. [Debug]               func9: '?_Get_data@?$_String_alloc@U?$_String_base_types@DV?$allocator@D@std@@@std@@@std@@QEBAAEBV?$_String_val@U?$_Simple_types@D@std@@@2@XZ'

16. [Debug]               Function demangled name is: 'public: class std::_String_val<struct std::_Simple_types<char>> const & std::_String_alloc<struct std::_String_base_types<char, class std::allocator<char>>>::_Get_data(void) const'

17. [Debug]               func10: '?_Get_second@?$_Compressed_pair@V?$allocator@D@std@@V?$_String_val@U?$_Simple_types@D@std@@@2@$00@std@@QEBAAEBV?$_String_val@U?$_Simple_types@D@std@@@2@XZ'

18. [Debug]               Function demangled name is: 'public: class std::_String_val<struct std::_Simple_types<char>> const & std::_Compressed_pair<class std::allocator<char>, class std::_String_val<struct std::_Simple_types<char>>, 1>::_Get_second(void) const'

Pourtant, une chose que nous pourrions faire pour gérer ce fouillis serait d’utiliser une sorte de mise en correspondance partielle / d’expression régulière entre le nom calculé de la fonction dé-décorée et une partie de ce nom que nous pourrions fournir comme entrée à la fonction de recherche. Par exemple, nous pourrions nous assurer qu’une entrée comme createClass correspondrait au nom dé-décoré class MyClass * createClass(int) et ainsi trouver le symbole ?createClass@@YAPEAVMyClass@@H@Z. Je ne suis pas sûr que ça en vaille vraiment la peine.

- Dans la plupart des cas, si vous écrivez le code JIT, il est de loin plus facile de juste exporter les fonctions que vous voulez utiliser en tant qu’extern “C”, ce qui vous permet d’éviter complètement le problème de la décoration.

En y réfléchissant, ça peut devenir utile dans une situation où nous devons appeler du code C++ existant sans interface C. Finalement, je devrais considérer ce point plus attentivement : cela pourrait être amusant !

VI - Programme de test mis à jour

Donc, finalement, pour m’assurer que cette implémentation du JIT fonctionnait comme attendu, j’ai mis à jour mon programme de test minimal avec le code suivant :

1. #include <llvm_common.h>

2. #include <iostream>

3. #include <NervJIT.h>

5. #ifdef DEBUG_MSG

6. #undef DEBUG_MSG

7. #endif

9. #define DEBUG_MSG(msg) std::cout << msg << std::endl;

11. int main(int argc, char *argv[])

12. {

13. #if 0

14. DEBUG_MSG("Running clang compilation...");

15. runClang({"W:/Projects/NervSeed/temp/test1.cxx",

16. "W:/Projects/NervSeed/temp/test2.cxx"});

17. DEBUG_MSG("Done running clang compilation.");

18. #else

19. DEBUG_MSG("Initializing LLVM...");

20. nv::initLLVM();

22. DEBUG_MSG("Creating NervJIT...");

23. nv::RefPtr<nv::NervJIT> jit = new nv::NervJIT();

25. DEBUG_MSG("loading modules");

26. jit->loadModuleFromFile("W:/Projects/NervSeed/temp/test1.cxx");

27. jit->loadModuleFromFile("W:/Projects/NervSeed/temp/test2.cxx");

28. // jit->loadModuleFromFile("W:/Projects/NervSeed/temp/test3.cxx");

29. jit->loadModuleFromBuffer(R"(

30. int nv_add3(int a, int b)

31. {

32. return (a+b)*3;

33. }

35. int nv_sub3(int a, int b)

36. {

37. return (a-b)*3;

38. }

39. )");

41. typedef int(*Func)(int, int);

43. Func add1 = (Func)jit->lookup("int nv_add(int, int)");

44. CHECK_RET(add1!=nullptr,1,"Invalid nv_add function.");

45. DEBUG_MSG("nv_add(12,3) = "<< add1(12,3));

46. // Func add2 = (Func)jit->lookup("nv_add2");

47. Func add2 = (Func)jit->lookup("int nv_add2(int, int)");

48. CHECK_RET(add2!=nullptr,1,"Invalid nv_add2 function.");

49. DEBUG_MSG("nv_add(12,3) = "<< add2(12,3));

50. Func add3 = (Func)jit->lookup("int nv_add3(int, int)");

51. CHECK_RET(add3!=nullptr,1,"Invalid nv_add3 function.");

52. DEBUG_MSG("nv_add(12,3) = "<< add3(12,3));

54. DEBUG_MSG("Destroying NervJIT...");

55. jit = nullptr;

57. DEBUG_MSG("Uninitializing LLVM...");

58. nv::uninitLLVM();

59. #endif

61. DEBUG_MSG("Exiting...");

62. return 0;

63. }

Et ça a marché comme attendu, cool ! ????

Au cas où ce code intéresserait quelqu’un, voici un ZIP contenant tous les fichiers discutés dans leur version courante : nvllvm_20200414.zip.

Les fichiers de ce package ZIP ne compileront pas directement au déballage, bien sûr ! (Je ne partage pas mon projet NervSeed complet ici…) Mais le code fourni peut être utilisé au moins comme un patron.

VII - Étapes suivantes

- Clairement, je dois fournir un mécanisme plus flexible de spécification des fichiers d’en-tête avant d’exécuter la compilation.

- Il peut aussi valoir le coup de fournir un support de définition de variables macro (?).

- Je dois penser un peu plus au système de dé-décoration de nom de fonction : je ne suis toujours pas sûr qu’il ait sa place ici ou non.

- Je réfléchis que je devrais peut-être me débarrasser du système PassBuilder/Managers et que je devrais plutôt juste utiliser une implémentation d’un « ancien système de FunctionPassManager » comme décrite ici : https://llvm.org/docs/tutorial/BuildingAJIT2.html (peut-être que de cette manière je me débarrasserais de ces pointeurs que je ne peux pas supprimer pour le moment ?).

VIII - Remerciements Developpez.com

Ce tutoriel est la traduction de JIT C++ compiler with LLVM - Part 2. Nous tenons à remercier Thierry Jeanneret pour la traduction, Thibaut Cuvelier pour la relecture technique, Malick pour la mise au gabarit et Claude Leloup pour la relecture orthographique.