IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Compilation C++ JIT avec LLVM

Partie 5 – Bitcode, PCH, gestion des exceptions, liaison des modules et plus…

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. 4 commentaires Donner une note à l´article (5)

Article lu   fois.

Les deux auteur et traducteur

Traducteur : Profil Pro

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

Voilà maintenant quelque temps que je vous présentais mon dernier article sur mes expériences avec le compilateur JIT. J’ai beaucoup joué avec ce code ces derniers jours. Si vous ne vous en souvenez pas, mon but premier était de pouvoir générer du code C++ directement depuis le langage de scripting Lua. C’est exactement ce que j’ai fait, et en passant j’ai construit un “frontal” pour mon compilateur en Lua, qui évolue encore et que j’utilise maintenant pour exécuter la plupart de mes tests. Au cours de cette démarche j’ai aussi travaillé sur la génération d’entêtes précompilés (PCH), les constructeurs et destructeurs de modules LLVM, les tests unitaires C++ depuis des scripts, la gestion d’exceptions, et les soucis de liaison des modules. Je pense donc qu’il est grand temps d’arrêter de coder un moment et d’essayer de partager ce que j’ai appris sur tous ces points dans l’hypothèse que ceci puisse intéresser quelqu’un (ou en réalité, juste pour que je me souvienne de ce que j’ai fait si je dois y revenir un jour.

II. Frontal JIT pour Lua

Comme mentionné plus haut, j’ai construit une classe JITCompiler dans mon environnement Lua, que je peux ensuite utiliser pour construire et utiliser un objet C++ JIT. Je ne pense pas qu’il vaille la peine d’entrer dans tous les détails de la configuration des interfaces Lua pour obtenir ce résultat. Disons juste que j’ai utilisé l’excellente bibliothèque sol3 pour générer ces interfaces et que j’ai simplement fourni les interfaces de ma classe NervJIT class et quelques fonctions init/uninit pour pouvoir complètement activer/désactiver l’environnement JIT LLVM directement depuis Lua.

Voici une rapide indication de ce à quoi ressemblent les interfaces actuellement :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
#include <llvm_common.h>
#include <core_lua.h>
#include <NervJIT.h>
 
namespace nv
{
 
void loadLLVMBindings(sol::state& lua)
{
    logTRACE2("Loading Lua bindings for LLVM module.");
 
    auto space = lua["nv"].get_or_create<sol::table>();
 
    space["initLLVM"] = &initLLVM;
    space["uninitLLVM"] = &uninitLLVM;
 
    SOL_BEGIN_ENUM(space, "LLVMHeaderType")
    SOL_ENUM("SYSTEM", nv::NervJIT::HEADER_SYSTEM);
    SOL_ENUM("ANGLED", nv::NervJIT::HEADER_ANGLED);
    SOL_ENUM("QUOTED", nv::NervJIT::HEADER_QUOTED);
    SOL_END_ENUM()
 
    SOL_BEGIN_CLASS(space, "NervJIT", NervJIT)
    SOL_CALL_CONSTRUCTORS(class_t());
    SOL_CLASS_FUNC(loadModuleFromFiles);
    SOL_CLASS_FUNC(loadModuleFromFile);
    SOL_CLASS_FUNC(loadModuleFromBuffer);
    SOL_CLASS_FUNC(generatePCHFromFile);
    SOL_CLASS_FUNC(generatePCHFromBuffer);
    SOL_CLASS_FUNC(generateBitcodeFromFile);
    SOL_CLASS_FUNC(generateBitcodeFromBuffer);
    SOL_CLASS_FUNC(loadModuleBitcode);
    SOL_CLASS_FUNC(usePCHFile);
    SOL_CLASS_FUNC(clearMacroDefinitions);
    SOL_OV2_FUNCS(addMacroDefinition, void(std::string), void(const std::string&, const std::string&)); 
    SOL_CLASS_FUNC(clearHeaderSearchPaths);
    SOL_CLASS_FUNC(addHeaderSearchPath);
    SOL_CLASS_FUNC(addCurrentProcess);
    SOL_CLASS_FUNC(addDynamicLib);
    SOL_CUSTOM_FUNC(linkModule) = [](class_t& obj, const std::string& outfile, sol::table t, bool onlyNeeded, bool internalize, bool optimize, bool preserveUseListOrder)
    {
        U32 count = t.size();
        std::vector<std::string> inputs(count);
 
        for (U32 i = 0; i < count; ++i)
        {
            inputs[i] = t[i + 1];
        }
 
        obj.linkModule(outfile, inputs, onlyNeeded, internalize, optimize, preserveUseListOrder);
 
    };
    SOL_CUSTOM_FUNC(setupCommandLine) = [](class_t& obj, sol::table t) {
        U32 count = t.size();
        std::vector<std::string> args(count);
 
        for (U32 i = 0; i < count; ++i)
        {
            args[i] = t[i + 1];
        }
        obj.setupCommandLine(args);
    };
 
    SOL_CUSTOM_FUNC(call) = [](class_t& obj, const std::string& name) {
        auto func = (void(*)())obj.lookup(name);
        CHECK(func, "Cannot find function with name "<<name);
        try {
            func();
        }
        catch(const std::exception& e) {
            logERROR("Exception catched from JIT code: "<<e.what());
        }
        catch(...) {
            logERROR("Unknown exception catched from JIT code.");
        }
    };
    SOL_END_CLASS()
 
    logTRACE2("Done loading Lua bindings for LLVM module.");
}
 
}
 
NV_REGISTER_BINDINGS(LLVM)

N’essayez pas de compiler le code fourni ci-dessus : il contient une série de macros que j’ai définies moi-même par ailleurs, donc vous ne pourrez pas le compiler tel quel. Mais vous pouvez quand même vous faire une idée de ce dont nous disposerons ensuite dans Lua. Image non disponible

Beaucoup des méthodes NervJIT disponibles dans ce fichier d’interface sont nouvelles : nous n’en parlons dans aucun des articles précédents, mais ne vous en faites pas : nous les décrirons plus loin dans ce billet.

Le premier test que j’ai effectué en Lua a été d’essayer d’étendre les interfaces Lua directement depuis Lua lui-même ! J’ai donc écrit le script C++ suivant :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
#include <core_lua.h>
#include <lua/LuaManager.h>
#include <NervApp.h>
 
using namespace nv;
 
extern "C" void loadLuaBaseExtensions()
{
  auto& lman = LuaManager::instance();
  auto& lua = lman.getMainState();
 
  lua["nvFileExists"] = &fileExists;
  logDEBUG("Done loading Lua extensions.");
};

Ensuite je charge ce script directement depuis Lua par quelque chose comme ceci :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
local startTime = nv.SystemTime.getCurrentTime()
  logDEBUG("JIT: Loading Lua extensions...")
  self:loadBitcodeForFile(self.script_dir.."lua_base_extensions.cpp")

  -- self.jit:generateBitcodeFromFile(self.script_dir.."lua_base_extensions.cpp", self.bc_dir.."lua_base_extensions.bc")
  -- self.jit:loadModuleBitcode(self.bc_dir.."lua_base_extensions.bc")
  
  -- self.jit:loadModuleFromFile(self.script_dir.."lua_base_extensions.cpp")
  local endTime = nv.SystemTime.getCurrentTime()
  logDEBUG(string.format("Script compiled in %.3fms", (endTime - startTime)*1000.0))

  self.jit:call("loadLuaBaseExtensions")
  • Et ça a marché sans problème sérieux ! Si vous y réfléchissez un peu c’est déjà une fonction plutôt élégante : dans le frontal Lua JITCompiler, nous avons aussi un “loadBitcodeForBuffer” par exemple, donc ceci signifie que vous pouvez envisager d’étendre Lua avec des extensions C++ concrètes sans même quitter le script Lua où vous voulez utiliser cette extension par un code tel que :
 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
local jit = require "base.JITCompiler"

jit:loadBitcodeForBuffer[[
#include <core_lua.h>
#include <lua/LuaManager.h>
#include <NervApp.h>

using namespace nv;

extern "C" void loadLuaBaseExtensions()
{
  auto& lman = LuaManager::instance();
  auto& lua = lman.getMainState();

  lua["nvFileExists"] = &fileExists;
  logDEBUG("Done loading Lua extensions.");
};
]]

jit:execute("loadLuaBaseExtensions")

if nvFileExists("C:/temp/dummy.txt") then
    logDEBUG("Yes! my nvFileExists function is really available!")
else
    logDEBUG("Never mind! My nvFileExists function is really available anyway! :-)")
end

III. Sérialisation du bitcode pour un module LLVM

Dans la section précédente nous avons vu que nous pouvons « charger » du « bitcode » soit depuis un fichier source C++ ou depuis du code C++ provenant d’un tampon mémoire ( c’est-à-dire une simple chaîne de caractères en L ua). Maintenant ce “bitcode” est quelque chose de nouveau que nous n’avons pas encore mentionné dans les articles précédents. Mais la raison est vraiment assez simple  :

  • Initialement, dans ma classe NervJIT je fournissais simplement un fichier source C++ et générais un module LLVM en mémoire directement à partir de ce fichier. Mais avec mes divers tests de compilation, j’ai commencé à réaliser que compiler à partir de fichiers source prendrait toujours un temps significatif dans certains cas, et qu’il ferait sens d’essayer de cacher le module résultant de la compilation d’un script donné, de sorte que nous puissions simplement réutiliser le module correspondant dans le cas où le contenu de ce script n’a pas été modifié la prochaine fois que nous en avons besoin. C’est là que le bitcode entre en jeu Image non disponible LLVM peut lire/écrire ses modules de représentation interne (IR) en tant que fichiers bitcode (habituellement nous utiliserons l’extension .bc pour ceux-ci).

En conséquence, dans notre compilateur NervJIT, plutôt que de générer directement un objet Module à partir d’un fichier source donné, nous écrivons plutôt ce Module généré comme fichier bitcode (cette étape n’est exécutée que si elle est vraiment nécessaire), puis nous chargeons l’objet Module en lisant le contenu du fichier bitcode, avant d’injecter ce module dans notre instance LLJIT.

IV. Écrire le bitcode dans un fichier

La fonction principale pour générer le fichier bitcode est la suivante :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
void NervJITImpl::generateBitcode(std::string outFile)
{
    auto& compilerInvocation = compilerInstance->getInvocation();
    auto& frontEndOptions = compilerInvocation.getFrontendOpts();
    std::string prevFile = std::move(frontEndOptions.OutputFile);
    frontEndOptions.OutputFile = std::move(outFile);
 
    // keep a copy of the current program action:
    auto prevAction = frontEndOptions.ProgramAction;
    frontEndOptions.ProgramAction = clang::frontend::EmitBC;
 
    if (!compilerInstance->ExecuteAction(*emit_bc_action))
    {
        ERROR_MSG("Cannot execute emit_bc_action with compiler instance!");
    }
 
    // Restore the previous values:
    frontEndOptions.OutputFile = std::move(prevFile);
    frontEndOptions.ProgramAction = prevAction;
}

Notez qu’ici la fonction est appelée après que nous avons fourni un fichier ou un tampon d’entrée, par l’une des fonctions suivantes :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
void NervJITImpl::setInputFile(const std::string& filename)
{
    auto& compilerInvocation = compilerInstance->getInvocation();
    auto& frontEndOptions = compilerInvocation.getFrontendOpts();
    frontEndOptions.Inputs.clear();
    frontEndOptions.Inputs.push_back(clang::FrontendInputFile(llvm::StringRef(filename), clang::InputKind(clang::Language::CXX)));
}
 
void NervJITImpl::setInputBuffer(llvm::MemoryBuffer* buf)
{
    auto& compilerInvocation = compilerInstance->getInvocation();
    auto& frontEndOptions = compilerInvocation.getFrontendOpts();
    frontEndOptions.Inputs.clear();
    frontEndOptions.Inputs.push_back(clang::FrontendInputFile(buf, clang::InputKind(clang::Language::CXX)));
}

Je pense qu’il vaut la peine d’expliquer un peu ce que nous faisons dans generateBitcode  : à la base, nous utilisons le frontal clang lui-même pour générer le fichier bitcode pour nous à partir du fichier source. En fait, je pense que je fournis ici une implémentation simulant un appel tel que  :

 
Sélectionnez
1.
clang -emit-llvm -o foo.bc -c foo.c

Ainsi, nous conservons tout comme avant : la ligne de commande de configuration de l’invocation du compilateur, le chemin de recherche additionnel des entêtes, les définitions du préprocesseur, etc. Mais ensuite nous n’exécutons pas le EmitLLVMOnlyAction normal que nous avons utilisé jusqu’ici, à la place :

  1. Nous surchargeons le champ ProgramAction dans les «frontendOptions» de l’invocation par clang::frontal::EmitBC (j’assume que la valeur par défaut que je lirais ici en fonction de ma configuration d’invocation par défaut devrait être clang::frontal::EmitLLVMOnly) ;
  2. Nous faisons pointer l’option du frontal OutputFile vers l’endroit où nous voulons écrire le fichier .bc dans le processus ;
  3. Finalement nous demandons l’exécution de l’action “emit_bc_action” sur l’instance de notre compilateur . Notez que cette action est créée avant tout dans le nouveau constructeur de notre NervJITImpl :
 
Sélectionnez
1.
2.
action = std::make_unique<clang::EmitLLVMOnlyAction>(tsContext->getContext());
emit_bc_action = std::make_unique<clang::EmitBCAction>();

Une fois l’action terminée avec succès, nous récupérons le Module résultant écrit dans OutputFile et nous nettoyons les options du frontal (au cas où), en restaurant les valeurs par défaut que nous utilisions auparavant.

Il y a beaucoup d’actions similaires “emit LLVM” disponibles dans clang, telles que EmitLLVM, EmitLLVMOnly, EmitBC, etc. De ce que je comprends, EmitLLVMOnly générera un objet Module, mais n’écrira rien dans le fichier de sortie, EmitBC génère le bitcode et l’écrit en sortie et EmitLLVM pourrait générer du code assembleur, je n’en suis pas sûr. De toute façon, si vous avez besoin d’investiguer à ce propos vous pouvez démarrer par le fichier source clang/lib/frontal/CompilerInstance.cpp comme référence.

Il existe d’autres manières d’écrire un Module LLVM dans un fichier bitcode, par exemple, la fonction auxiliaire de LLVM WriteBitcodeToFile  : nous y reviendrons plus loin dans cet article.

V. Lire du bitcode à partir d’un fichier

La seconde partie du contrat est ensuite d’être capable de lire le bitcode contenu dans un fichier donné pour en recréer l’objet LLVM. Ceci est fait dans la classe NervJIT en utilisant la fonction auxiliaire LLVM parseIRFile :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
void NervJITImpl::loadModuleBitcode(const std::string& bcfile)
{
    // Note: we could also use getLazyIRFileModule here?
  
    llvm::SMDiagnostic Err;
    std::unique_ptr<Module> module(llvm::parseIRFile(llvm::StringRef(bcfile), Err, *tsContext->getContext()));
    if(!module) {
        THROW_MSG("Cannot load IR module from file "<<bcfile);
    }
 
    loadModule(std::move(module));
}

Cette partie est réellement directe, notez juste que nous utilisons une fonction auxiliaire void loadModule(std::unique_ptr<Module> module); pour exécuter «l’injection» de notre Module IR nouvellement créé dans notre session d’exécution JIT, car il y a d’autres considérations importantes à prendre en compte ici. ⇒ Nous reviendrons sur ce point un peu plus tard.

VI. Mécanisme de caching du bitcode

Une fois le précédent mécanisme de sérialisation implémenté, il était assez facile de mettre en place une couche de caching simple et minimaliste par-dessus : lorsque nous générons le bitcode à partir d’un fichier source, seuls quelques éléments clés peuvent modifier le résultat de la compilation :

  1. Les arguments de la ligne de commande que nous avons fournis pour configurer l’instance du compilateur ;
  2. Les chemins de recherche additionnels que nous avons spécifiés ;
  3. Les paramètres que nous avons fournis au préprocesseur ;
  4. Le contenu réel du script source que nous voulons compiler.

⇒ Donc dans ma classe JITCompiler, j’utilise des méthodes spécifiques pour définir et en même temps mettre en cache les paramètres de compilation (arguments de la ligne de commande, entêtes, macros) d’après lesquels je génère un hash SHA256, et je mets à jour le hash à chaque fois que les paramètres sont modifiés en appelant une fonction Lua simple telle que :

 
Sélectionnez
1.
2.
3.
4.
-- Méthode utilisée pour générer une valeur de hachage à partir d’une liste de chaînes de caractères :
function Class:computeStringListHash(list)
  return nv.sha256_from_buffer(table.concat(list, " "))
end

La fonction réelle sha256_from_buffer est implémentée en C++ en utilisant ces sources comme modèles : http://www.zedwood.com/article/cpp-sha256-function.

Ensuite, lorsque je dois compiler un fichier ou un tampon, je génère aussi un hachage pour le contenu du code source, puis j’obtiens un « hachage global » prenant aussi en compte les hachages de configuration évoqués auparavant :

 
Sélectionnez
1.
2.
3.
4.
5.
-- Obtenir le hache d’un tampon donné dans le contexte de compilation courant :
function Class:getContextualBufferHash(buf)
  local hash = nv.sha256_from_buffer(buf)
  return nv.sha256_from_buffer(self.current_context_hash .. hash)
end

Puis je transforme simplement cette chaîne de hachage en nom de fichier bitcode en ajoutant l’extension “.bc”

Finalement, pour savoir si je peux utiliser le résultat en bitcode d’une compilation mise en cache je dois juste vérifier si le fichier existe, et sinon, je compile et écris le fichier dans le processus :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
function Class:buildBitcodeForBuffer(buf, force)
  -- First we should compute the hash of the input buffer:
  local bufHash = self:getContextualBufferHash(buf)

  local bcfile = self.bc_dir..bufHash..".bc"
  if force or not nv.fileExists(bcfile) then
    logDEBUG("Generating bytecode file ", bcfile, "...")
    local startTime = nv.SystemTime.getCurrentTime()
    self.jit:generateBitcodeFromBuffer(buf, bcfile)
    local endTime = nv.SystemTime.getCurrentTime()
    logDEBUG(string.format("Bitcode compiled in %.3fms", (endTime - startTime)*1000.0))
  end

  return bcfile
end

function Class:loadBitcodeForBuffer(buf, force)
  local bcfile = self:buildBitcodeForBuffer(buf, force)
  -- Finally we load the generated bytecode:
  self.jit:loadModuleBitcode(bcfile)
end

Note importante : il existe malheureusement deux limitations importantes à ce mécanisme de mise en cache (mais je peux vivre avec elles pour le moment, donc ce système est suffisant pour mes besoins) :

  1. Comme décrit plus haut, les « haches de contexte de compilation » ne prennent pas en compte les changements apportés aux entêtes inclus dans le script source. Donc si vous n’êtes pas prudent à ce sujet vous pouvez finir par utiliser un Module qui n’incorpore pas les derniers changements que vous aurez apportés à vos entêtes uniquement. (Mais évidemment il y a quelques options permettant de corriger ce problème au besoin).
  2. Avec ce système vous produirez beaucoup de fichiers au format « nom_de_hachage.bc » tels que : “fffdc84aec07abae174c32795464209fb8e85bd2b6e3fea29521ce0da6bb8831.bc” dans un répertoire donné, et chaque fois que vous modifierez le contenu de votre script (ou à d’autres éléments du contexte de compilation) vous obtiendrez un hachage différent pour un code contenu quasiment ou totalement identique. Donc vous avez les deux faces de la pièce : d’un côté c’est bien, car cela signifie que si durant vos tests vous revenez à un hachage déjà généré, vous disposez déjà du fichier de cache et vous épargnez du temps de compilation. De l’autre côté, évidemment, vous obtenez des « fichiers cache orphelins » qui s’accumuleront avec le temps et qui nécessiteront un nettoyage de temps en temps. (Mais de nouveau, ça ne semble pas être une limitation trop sérieuse de mon point de vue, et je pense qu’il y a plusieurs « manières appropriées » de traiter ce point.)

VII. Gestion des entêtes précompilés (PCH – Precompiled Headers)

Je pense que le point important suivant à discuter ici est le support des entêtes précompilés par votre compilateur JIT. C’est important, car souvent nous pouvons construire de « gros modules » à partir de plusieurs fichiers sources C++ et utiliser les PCH peut sauver bien du temps de compilation dans ce cas.

Nous devons donc gérer deux parties pour ce faire : d’un côté nous devons fournir les moyens de générer un fichier PCH et de l’autre nous devons fournir un fichier PCH en « entrée » lorsque nous compilons ensuite les fichiers source.

VII-A. Génération des PCH

Voici la fonction principale que j’utilise pour générer le fichier PCH :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
void NervJITImpl::generatePCH(std::string outFile)
{
    auto& compilerInvocation = compilerInstance->getInvocation();
    auto& frontEndOptions = compilerInvocation.getFrontendOpts();
    std::string prevFile = std::move(frontEndOptions.OutputFile);
    frontEndOptions.OutputFile = std::move(outFile);
 
    // keep a copy of the current program action:
    auto prevAction = frontEndOptions.ProgramAction;
    frontEndOptions.ProgramAction = clang::frontend::GeneratePCH;
 
    if (!compilerInstance->ExecuteAction(*gen_pch_action))
    {
        ERROR_MSG("Cannot execute gen_pch_action with compiler instance!");
    }
 
    // Restore the previous values:
    frontEndOptions.OutputFile = std::move(prevFile);
    frontEndOptions.ProgramAction = prevAction;
}

Comme vous pouvez le voir, l’idée est très similaire à ce que nous avons déjà fait pour générer les fichiers bitcode des modules dans la section précédente : de nouveau, nous “conservons tout le reste tel quel” et lorsque nous faisons une requête de génération d’un fichier PCH nous changeons juste le ProgramAction et le OutputFile des options du frontal de l’invocation de notre compilateur. L’action que nous utilisons cette fois est la suivante (aussi construite dans le constructeur du NervJITImpl) :

 
Sélectionnez
1.
gen_pch_action = std::make_unique<clang::GeneratePCHAction>();

Et fondamentalement, c’est tout ! Par convention vous spécifierez une extension « .pch » pour votre nom de fichier dans ce cas, et si l’action est exécutée sans erreur ce fichier est généré et votre prochaine étape sera de l’utiliser pour exécuter la véritable génération du module.

Note : cette implémentation fait, je pense, la même chose que ce que vous obtenez par la version ligne de commande :

 
Sélectionnez
1.
clang -cc1 test.h -emit-pch -o test.h.pch

VII-B. Utiliser les PCH

Il est très simple de spécifier qu’un fichier PCH donné doit être utilisé pour les compilations suivantes, nous avons juste à mettre à jour les options appropriées du préprocesseur :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
void NervJIT::usePCHFile(std::string pchfile)
{
    auto& compilerInvocation = impl->compilerInstance->getInvocation();
    auto& opts = compilerInvocation.getPreprocessorOpts();
    opts.ImplicitPCHInclude = std::move(pchfile);
}

Ensuite vous pouvez générer votre module et le bitcode comme d’habitude, et le contenu du PCH sera utilisé comme attendu. Pas trop à ajouter à ce stade, d’accord ?

Du côté de Lua j’utilise aussi un mécanisme de caching des PCH strictement équivalent au mécanisme de caching du bitcode décrit plus tôt. Donc de mon côté je génère aussi un fichier pch dont le nom correspond au contenu du fichier qui a été utilisé pour générer le PCH incluant les haches du contexte de compilation.

VIII. Émulation de la désactivation du TLS

En poursuivant mes tests de script C++, j’ai été confronté à un autre problème sérieux lorsque j’ai essayé d’utiliser des variables statiques dans mes fonctions. Par exemple j’obtiens une erreur de compilation lorsque j’essaie de compiler cette fonction minimale de test :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
#include <core_common.h>
 
extern "C" void test_func() {
    static int t = (int)1000000*cos(34.5);
    logDEBUG("My int value is: "<<t);
}

⇒ Ceci provoque une erreur de symbole manquant :

JIT session error: Symbols not found: [ __emutls_v._Init_thread_epoch, __emutls_get_address, _Init_thread_header, _Init_thread_footer, _Init_thread_abort ]

Les “_Init_thread_header”, “_Init_thread_footer”, “_Init_thread_abort” ont été aisés à trouver, conduisant à la partie vcruntime de l’environnement d’exécution C de Microsoft. Je les ai donc ajoutés dans mon module llvm_syms d’où je réexporte tous les symboles requis qui sont autrement manquants lorsque je lie mes modules JIT.

Pour mémoire, dans mes billets précédents, j’ai appelé ce module d’exportation llvm_helper, puis j’ai vraiment essayé d’exporter tous les symboles dont j’avais besoin directement depuis ma bibliothèque partagée nvLLVM. Mais à la fin j’ai dû revenir à l’utilisation d’un module auxiliaire dédié que j’ai nommé llvm_syms pour exporter ces symboles manquants, car sinon j’obtenais des symboles dupliqués lorsque j’essayais de lier la bibliothèque nvLLVM à mes bibliothèques de liaison Lua LLVM…

Mais ensuite je n’ai pas du tout pu trouver d’où __emutls_v._Init_thread_epoch et __emutls_get_address pouvaient bien venir. En plongeant plus profondément dans les sources de LLVM, j’ai finalement réalisé que ces fonctions étaient en fait utilisées pour une sorte de couche d’émulation TLS (TLS est l’acronyme de Thread Local Storage, Stockage local au thread) qui est utilisée sur certaines plateformes lorsque le TLS natif est indisponible.

⇒ Normalement vous pouvez activer/désactiver l’émulation TLS explicitement par les arguments de ligne de commande de clang -femulated-tls (ou -fno-emulated-tls), et ceci mettra à jour les entrées EmulatedTLS et ExplicitEmulatedTLS des codegen options de la configuration de l’invocation de votre compilateur. Mais ça n’a pas semblé marcher pour moi comme je l’attendais : dans mon compilateur NervJIT, il semble que j’utilisais toujours l’émulation TLS. Ça m’a pris pas mal de temps pour comprendre ce qui se passait là, mais j’ai finalement lu plus attentivement les commentaires de la fonction JITTargetMachineBuilder::detectHost() :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
/// Create a JITTargetMachineBuilder for the host system.
///
/// Note: TargetOptions is default-constructed, then EmulatedTLS and
/// ExplicitEmulatedTLS are set to true. If EmulatedTLS is not
/// required, these values should be reset before calling
/// createTargetMachine.
static Expected<JITTargetMachineBuilder> detectHost();
 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
auto jtmb = CHECK_LLVM(JITTargetMachineBuilder::detectHost());
 
// Resetting emulateTLS to false:
auto& tgtOpts = jtmb.getOptions();
tgtOpts.EmulatedTLS = false;
tgtOpts.ExplicitEmulatedTLS = false;
     
targetMachine = CHECK_LLVM(jtmb.createTargetMachine());
DEBUG_MSG("Target machine using emulated TLS: "<<(targetMachine->useEmulatedTLS() ? "YES":"NO"));
 
auto dl = CHECK_LLVM(jtmb.getDefaultDataLayoutForTarget());
DEBUG_MSG("Default layout prefix for target: '" << dl.getGlobalPrefix() << "'");
 
DEBUG_MSG("Creating LLJIT object.");
 
LLJITBuilder llb;
llb.setJITTargetMachineBuilder(std::move(jtmb)).setNumCompileThreads(2);
 
lljit = CHECK_LLVM(llb.create());
     
DEBUG_MSG("Done creating LLJIT object.")

Et avec cette modification, les symboles “__emutls_XXX” disparurent immédiatement ; à leur place j’ai obtenu deux nouveaux symboles (évidemment liés au problème courant) : _tls_index et _Init_thread_epoch. Pourtant, il s'avère que ces deux symboles étaient disponibles dans l’environnement d’exécution C de Microsoft cette fois ! Donc de nouveau, exportation du nécessaire depuis mon module llvm_syms :

 
Sélectionnez
1.
2.
3.
4.
5.
#pragma comment(linker, "/export:_Init_thread_header")
#pragma comment(linker, "/export:_Init_thread_footer")
#pragma comment(linker, "/export:_Init_thread_abort")
#pragma comment(linker, "/export:_tls_index")
#pragma comment(linker, "/export:_Init_thread_epoch")

Et ça a marché ! J’ai pu compiler et exécuter la fonction de test mentionnée sans aucun problème. Ouf ! Ce n’était pas simple…

IX. Tests unitaires scriptés C++ - Première tentative

À ce point, je me sentais suffisamment confiant pour envisager d’essayer quelque chose de « plus grand » : donc j’ai pensé que je devrais essayer des tests unitaires C++ directement avec ces scripts. Malheureusement, il semble a posteriori que c’était un peu prématuré. Explications

Jusqu’à présent, j’ai utilisé l’environnement de test Boost pour construire des tests unitaires normaux, mais j’ai décidé que je devais saisir cette occasion pour améliorer mon environnement de test avec quelque chose de nouveau. J’étais particulièrement intéressé par des solutions entêtes seules, donc, évidemment, j’ai vite trouvé le projet Catch/Catch2, qui semblait très prometteur et intéressant.

⇒ Donc bien sûr j’ai décidé de l’essayer… mais je n’ai pas eu beaucoup de chance sur cette voie. J’ai passé un temps significatif à essayer de comprendre ce qui ne marchait pas (en ajoutant des affichages de débogage Catch partout puis en essayant d’exécuter les scripts test) et j’ai bien progressé, mais en ce moment cette option ne fonctionne toujours pas pour moi.

En tout cas, en voyant que Catch ne me conduirait nulle part avant longtemps, j’ai pensé que je devrais aussi tester l’environnement Boost… mais ici non plus, je ne suis pas parvenu à le faire fonctionner pour le moment : j’ai essayé beaucoup de choses, mais avec celui-ci je reste coincé par des erreurs de segmentation… dommage.

Conclusion : le premier essai d’exécuter des scripts de test unitaire C++ ne s’est pas bien déroulé du tout. Mais tout de même, comme je l’ai dit, j’ai découvert quelques choses intéressantes en essayant de comprendre pourquoi l’environnement Catch ne fonctionnait pas comme prévu :

  1. Premièrement j’ai constaté que mes variables globales/statiques n’étaient simplement pas construites dans mes scripts C++ ;
  2. Et aussi, que les exceptions C++ n’étaient apparemment pas gérées correctement dans mon code JIT.

⇒ Les deux étaient des problèmes plutôt sérieux, donc je devais investiguer.

X. Construction et destruction de globaux

Comme je l’ai dit, les « globaux » ne fonctionnaient pas dans mon code JIT, donc j’ai préparé un test de référence minimal pour étudier ce problème :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
local jit = import "base.JITCompiler"

jit:runFunction("test_func", [[
#include <iostream>

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

class MyTest {
public:
    MyTest() {
        std::cout << "Creating a MyTest object." << std::endl;
    }

    ~MyTest() {
        std::cout << "Deleting a MyTest object." << std::endl;
    }

    void hello() {
        std::cout << "Hello!" << std::endl;
    }
};

static MyTest test;

extern "C" void test_func() {
    DEBUG_MSG("Running test function.");
    //test.hello();
}
]])

En exécutant ce script Lua, je m’attendais d’abord à recevoir le message “Creating a MyTest object.” et ensuite à la fin du script (lorsque mon compilateur JIT est finalement chargé), le message “Deleting a MyTest object.”, mais bien sûr je n’ai d’abord rien obtenu.

Dans le script C++ ci-dessus, j’ai déclaré un objet static MyTest test; , mais en fait ce problème et sa solution sont exactement les mêmes si je n’utilise pas le spécificateur static.

Bon, retour chez mon meilleur ami (Google lol !) : à la recherche d’explications sur ce qui peut se passer et que faire… et finalement j’ai trouvé que ceci avait affaire avec l’initialisation et désinitialisation des modules LLVM. Plus précisément, lorsque vous chargez un module dans votre session JIT, vous êtes supposé exécuter les fonctions du constructeur disponibles dans ce module et vous devriez aussi exécuter les fonctions du destructeur lorsque ce module sort de la portée ou que vous détruisez la session JIT.

Maintenant, le problème est que ce processus d’init/uninit a évolué significativement dernièrement :

• initialement vous « collectiez » tous les constructeurs dans un llvm::orc::CtorDtorRunner, puis ajoutiez votre module au JIT, puis exécutiez ces constructeurs comme suit :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
llvm::orc::CtorDtorRunner R(lljit->getMainJITDylib());
R.add(llvm::orc::getConstructors(*module));
 
DEBUG_MSG("Adding module from bytecode.");
auto err = lljit->addIRModule(ThreadSafeModule(std::move(module), *tsContext));
checkLLVMError(std::move(err));
 
checkLLVMError(R.run());

⇒ Pour plus de détails, vous pouvez jeter un œil à cette discussion : https://lists.llvm.org/pipermail/llvm-dev/2019-March/131057.html ;

  • puis à un moment donné dans l’implémentation de la version 10 de LLVM les fonctions runConstructors()/runDestructors() ont été introduites dans la classe LLJIT. Donc vous devriez plutôt faire :
 
Sélectionnez
1.
2.
3.
4.
5.
DEBUG_MSG("Adding module from bytecode.");
auto err = lljit->addIRModule(ThreadSafeModule(std::move(module), *tsContext));
checkLLVMError(std::move(err));
 
checkLLVMError(lljit->runConstructors());

Et maintenant dans la version 11 de LLVM (la version courante sur git), les fonctions runConstructors()/runDestructors() ont été supprimées et remplacées par les fonctions initialize(…)/uninitialize(…) :

 
Sélectionnez
1.
2.
3.
4.
5.
DEBUG_MSG("Adding module from bytecode.");
auto err = lljit->addIRModule(ThreadSafeModule(std::move(module), *tsContext));
checkLLVMError(std::move(err));
 
checkLLVMError(lljit->initialize(lljit->getMainJITDylib()));

⇒ Pour plus de détails sur cette dernière implémentation vous pouvez commencer par cette page : https://groups.google.com/forum/#!msg/llvm-dev/DU5YYthVbrY/wXR1zZ7TAAAJ

J’ai testé les trois options décrites ci-dessus (car je suis passé temporairement à LLVM version 10.0.0 à un moment donné du processus) : toutes semblaient donner des résultats similaires, donc maintenant je reste à la dernière version officiellement disponible, utilisant les fonctions initialize()/uninitialize(). Et donc voici la fonction loadModule(std::unique_ptr<Module> module) que j’ai mentionnée plus tôt, que j’utilise pour m’assurer que les globaux du module sont initialisés de manière appropriée :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
void NervJITImpl::loadModule(std::unique_ptr<Module> module)
{
    // llvm::orc::CtorDtorRunner R(lljit->getMainJITDylib());
    // R.add(llvm::orc::getConstructors(*module));
 
    DEBUG_MSG("Adding module from bytecode.");
    auto err = lljit->addIRModule(ThreadSafeModule(std::move(module), *tsContext));
    checkLLVMError(std::move(err));
 
    // Now we should try to run the static initializers if any:
    DEBUG_MSG("Calling dyn lib initialize()");
    checkLLVMError(lljit->initialize(lljit->getMainJITDylib()));
    // checkLLVMError(lljit->runConstructors());
    // checkLLVMError(R.run());
    DEBUG_MSG("Done calling dyn lib initialize()");
}

Et bien sûr j’appelle aussi uninitialize() pour mon JITDylib principal dans le destructeur du NervJITImpl pour un nettoyage correct :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
NervJITImpl::~NervJITImpl()
{
    // We should uninitialize our main dyn library here:
    DEBUG_MSG("Uninitializing main JIT lib.");
    checkLLVMError(lljit->deinitialize(lljit->getMainJITDylib()));
    DEBUG_MSG("Done uninitializing main JIT lib.");
}

Malheureusement ce n’était pas encore suffisant pour que mon script de test minimal fonctionne correctement : bien sûr je pouvais voir maintenant que mon constructeur global était appelé comme attendu, mais ensuite le destructeur correspondant n’était pas appelé lorsque je détruisais mon compilateur JIT, et à la place j’obtenais inévitablement une erreur de segmentation à la toute fin de l’exécution de mon programme. Bien, à ce stade il me semblait que le destructeur global était enregistré avec mon gestionnaire de “program level” atexit() ce qui est exactement le problème décrit par Lang Hames dans la seconde partie de cette page : et c’est là que le LocalCXXRuntimeOverrides est supposé intervenir.

Après quelques investigations, j’ai trouvé dans les sources de LLVM 10.0.0 que le fichier llvm/tools/lli/lli.cpp contient un exemple d’usage (censément) fonctionnel de cet utilitaire :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
orc::MangleAndInterner Mangle(J->getExecutionSession(), J->getDataLayout());
 
orc::LocalCXXRuntimeOverrides CXXRuntimeOverrides;
ExitOnErr(CXXRuntimeOverrides.enable(J->getMainJITDylib(), Mangle));
 
// Then later on destruction, we call:
CXXRuntimeOverrides.runDestructors();

J’ai donc essayé d’utiliser ceci dans mon code, mais une fois encore ça n’a pas réellement marché pour moi :

  1. D’abord, c’est uniquement compatible avec la version 10.0.0 de LLVM : dans la version 11.0.0, il semble que la classe LLJIT enregistre déjà des symboles absolus pour les noms que cet utilitaire essaie aussi d’enregistrer, et vous obtenez donc des problèmes de duplicate symbols :

     
    Sélectionnez
    1.
    2.
    3.
    4.
    5.
    6.
    7.
    8.
    9.
    10.
    11.
    12.
    Error LocalCXXRuntimeOverrides::enable(JITDylib &JD,
                                            MangleAndInterner &Mangle) {
      SymbolMap RuntimeInterposes;
      RuntimeInterposes[Mangle("__dso_handle")] =
        JITEvaluatedSymbol(toTargetAddress(&DSOHandleOverride),
                           JITSymbolFlags::Exported);
      RuntimeInterposes[Mangle("__cxa_atexit")] =
        JITEvaluatedSymbol(toTargetAddress(&CXAAtExitOverride),
                           JITSymbolFlags::Exported);
     
      return JD.define(absoluteSymbols(std::move(RuntimeInterposes)));
    }
    
  2. Même en utilisant LLVM 10.0.0, j’obtenais encore mon erreur de segmentation lorsque j’utilisais cet utilitaire… Ça m’a pris assez longtemps pour comprendre ce qui se passait, mais j’ai finalement réalisé que c’était parce que d’une certaine manière, le code que je génère dans mon compilateur JIT n’enregistre pas le destructeur avec le gestionnaire “__cxa_atexit” , mais plutôt avec l’ancien gestionnaire “atexit”, obsolète. Malheureusement, je n’ai pas réellement d’idée du pourquoi pour le moment.

J’ai essayé différentes options de ligne de commande, “-fno-use-cxa-atexit”, “-fuse-cxa-atexit”, “-fregister-global-dtors-with-atexit”, mais aucune d’elles n’a aidé à utiliser le gestionnaire cxa_atexit. Mais souvenez-vous que je suis sur une plateforme Windows 10 et que j’utilise tous les flags de compatibilité de Visual Studio… c’est peut-être une partie de l’explication (ou peut-être que je dois essayer plus fort…).

Mais de toute façon cela m’a donné une idée : je pourrais simplement imiter ce que fait l’utilitaire LocalCXXRuntimeOverrides utility et ainsi fournir mon propre gestionnaire atexit. J’ai donc abouti à ce code :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
typedef void(*ExitFunc)();
std::vector<ExitFunc> exitFuncList;
 
static int AtExitOverride(ExitFunc func)
{
    DEBUG_MSG("Registering destructor in my AtExit override: "<<(const void*)func);
    exitFuncList.push_back(func);
    return 0;
}
 
static void runDestructors() {
    // We execute the functions in LIFO order:
    while(!exitFuncList.empty()) {
        DEBUG_MSG("Executing one at exit function.")
        exitFuncList.back()();
        exitFuncList.pop_back();
        DEBUG_MSG("Done executing one at exit function.")
    }
}
 
NervJITImpl::NervJITImpl()
{
    // Creating lljit object here.
 
    {
        auto& JD = lljit->getMainJITDylib();
        SymbolMap RuntimeInterposes;
 
        // We need to manually take care of the atexit function itself:
        RuntimeInterposes[(*mangler)("atexit")] =
            JITEvaluatedSymbol(toTargetAddress(&AtExitOverride),
                            JITSymbolFlags::Exported);
        checkLLVMError(JD.define(absoluteSymbols(std::move(RuntimeInterposes))));
    }
}
 
NervJITImpl::~NervJITImpl()
{
    // We should uninitialize our main dyn library here:
    DEBUG_MSG("Uninitializing main JIT lib.");
    checkLLVMError(lljit->deinitialize(lljit->getMainJITDylib()));
    runDestructors();
    DEBUG_MSG("Done uninitializing main JIT lib.");
}

⇒ Et avec ces améliorations mon script de test a finalement fonctionné ! Il m’a donné ce type de sortie :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
[Debug]               Bitcode compiled in 1221.376ms
[DEBUG]: Adding module from bytecode.
[DEBUG]: Calling dyn lib initialize()
Creating a MyTest object.
[DEBUG]: Registering destructor in my AtExit override: 000002B73EE00340
[DEBUG]: Done calling dyn lib initialize()
Running test function.
[DEBUG]: Deleting NervJIT object.
[DEBUG]: Uninitializing main JIT lib.
[DEBUG]: Executing one at exit function.
Deleting a MyTest object.
[DEBUG]: Done executing one at exit function.
[DEBUG]: Done uninitializing main JIT lib.
[DEBUG]: Deleted NervJIT object.
Deleted LogManager object.

Et ceci conclut cette section sur le support de la construction et de la destruction des globaux dans notre compilateur JIT. Il est temps de passer maintenant au second problème significatif que j’ai découvert en essayant d’exécuter mes tests unitaires C++ avec Catch depuis le code JIT : le support des exceptions C++.

XI. La gestion des exceptions C++ (sous Win64)

D’après ce que j’ai lu sur Internet (principalement depuis les archives des emails de llvm.org) la gestion des exceptions en C++ sous windows 64 ne semble pas réellement supportée à l’intérieur du code compilé JIT : c’est un sujet plutôt complexe, je ne prétends donc pas tout comprendre, mais j’ai construit le script de test minimal suivant pour ce thème :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
local jit = import "base.JITCompiler"

jit:runFunction("test_func", [[
#include <iostream>

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

extern "C" void test_func() noexcept(false) {
    DEBUG_MSG("Begin test.")
    try {
        DEBUG_MSG("I'm throwing an exception.");
        throw std::exception("My exception message");
    }
    catch(const std::exception& e) {
       DEBUG_MSG("Catched exception: "<<e.what());
    }
    catch(...) {
        DEBUG_MSG("Catched exception.");
    }
    DEBUG_MSG("End test.")
    
    throw std::exception("Throwing from extern C :-)!");
    DEBUG_MSG("Real end test.")
}
]])

Et bien sûr ceci n’a pas du tout fonctionné pour moi (le programme plante simplement après le message “I'm throwing an exception.”).

Pourtant nous devrions être capables de compiler ce code sans souci, mais gardez en mémoire que nous devons autoriser la gestion des exceptions en utilisant d’abord les arguments de ligne de commande “-fcxx-exceptions”, “-fexceptions” and “-fexternc-nounwind”.

Donc, de nouveau, j’ai passé un temps significatif en ligne à essayer de trouver une solution appropriée à ce problème (ou au strict minimum quelques indications sur la manière de le gérer) et finalement j’ai trouvé cette page de revue des commits : https://reviews.llvm.org/D35103.

En réalité je ne suis pas vraiment sûr de ce qui se passe avec cette liste de commits. C’est assez vieux (ça a été fermé en mars 2018), mais cette « propriété » ne semble plus être présente nulle part dans les sources de la version courante de LLVM, donc ?

⇒ De toute façon c’est le seul modèle valable que j’ai trouvé donc je devais vraiment essayer de l’utiliser. Fondamentalement, j’ai repris de ce code l’implémentation complète de la classe SingleSectionMemoryManager et l’ai conservé à peu près tel quel : le seul changement réel que j’ai apporté à ce niveau a été dans le constructeur de SingleSectionMemoryManager : là je n’enregistre pas de symbole pour la fonction “_CxxThrowException” (j’ai d’abord essayé, mais ça n’a pas semblé fonctionner : et de toute façon avec la classe LLJIT vous êtes supposé procéder différemment je le crains, donc j’y reviendrai juste après) :

 
Cacher/Afficher le codeSélectionnez

Je pense que la modification que j’ai faite dans le symbole “_CxxThrowException” est en fait ce qui est suggéré dans le commentaire “Image non disponible” ci-dessus.

Même chose pour la classe SEHFrameHandler : je pourrais juste la laisser telle quelle et elle compilerait très bien :

 
Cacher/Afficher le codeSélectionnez

Ensuite nous devons apporter les changements requis pour rendre le code fourni compatible avec la classe LLJIT de LLVM 11.0.0 :

  1. Premièrement nous devons injecter le symbole pour “_CxxThrowException” dans notre librairie dynamique JIT, donc j’utilise ce code modifié pour y parvenir
 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
NervJITImpl::NervJITImpl()
{
    // Creating lljit object here.
 
    {
        auto& JD = lljit->getMainJITDylib();
        SymbolMap RuntimeInterposes;
 
        // We need to manually take care of the atexit function itself:
        RuntimeInterposes[(*mangler)("atexit")] =
            JITEvaluatedSymbol(toTargetAddress(&AtExitOverride),
                            JITSymbolFlags::Exported);
#if NV_JIT_HANDLE_EXCEPTIONS
        RuntimeInterposes[(*mangler)("_CxxThrowException")] =
            JITEvaluatedSymbol(toTargetAddress(&SEHFrameHandler::RaiseSEHException),
                            JITSymbolFlags::Exported);
#endif
 
        checkLLVMError(JD.define(absoluteSymbols(std::move(RuntimeInterposes))));
    }
}

Et ensuite nous devons aussi dire explicitement à notre objet LLJIT d’utiliser ce nouveau “SingleSectionMemoryManager” que nous venons d’implémenter. Dans le code de référence de « l’interpréteur clang » c’était fait comme suit :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
// (Warning: this code doesn't apply when building a LLJIT object)
static llvm::ExecutionEngine *
createExecutionEngine(std::unique_ptr<llvm::Module> M, std::string *ErrorStr) {
  llvm::EngineBuilder EB(std::move(M));
  EB.setErrorStr(ErrorStr);
  EB.setMemoryManager(llvm::make_unique<SingleSectionMemoryManager>());
  llvm::ExecutionEngine *EE = EB.create();
  EE->finalizeObject();
  return EE;
}

… Mais bien sûr cela ne s’applique pas pour nous, car nous n’avons aucun composant ExecutionEngine dans notre objet LLJIT autant que je le sache. Donc en cherchant dans les sources de LLJIT, j’ai finalement trouvé comment vous êtes supposé le faire dans les nouvelles implémentations : vous devez fournir un ObjectLinkingLayerCreator à votre LLJIT builder comme montré ci-dessous :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
LLJITBuilder llb;
    llb.setJITTargetMachineBuilder(std::move(jtmb)).setNumCompileThreads(2);
#if NV_JIT_HANDLE_EXCEPTIONS
    // We use our custom memory manager here:
    llb.setObjectLinkingLayerCreator([](ExecutionSession &ES, const Triple &triple) -> std::unique_ptr<ObjectLayer> {
        auto GetMemMgr = []() { return std::make_unique<SingleSectionMemoryManager>(); };
        auto ObjLinkingLayer = std::make_unique<RTDyldObjectLinkingLayer>(ES, std::move(GetMemMgr));
         
        // Note sure this is needed/appropriate ?
        if (triple.isOSBinFormatCOFF()) {
            ObjLinkingLayer->setOverrideObjectFlagsWithResponsibilityFlags(true);
            ObjLinkingLayer->setAutoClaimResponsibilityForObjectSymbols(true);
        }
         
        return std::unique_ptr<ObjectLayer>(std::move(ObjLinkingLayer));
    });
#endif
 
    lljit = CHECK_LLVM(llb.create());

À ce point, j’ai commencé à devenir nerveux : par expérience je dirais qu’ajouter autant de code lorsque vous ne comprenez pas pleinement ce qui se passe peut habituellement vous conduire sur une voie : plantées, scories, et plus de plantées… jusqu’à ce que vous compreniez finalement complètement ce que vous faites (et que vous commenciez à réaliser combien fou et stupide vous étiez auparavant…). Mais ça devait être mon jour de chance, car… cela a juste fonctionné !

Note : j’ai aussi mis à jour mes interfaces LLVM pour pouvoir intercepter les exceptions provenant de mon code JIT puisque cette implémentation était aussi supposée en fournir le support :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
SOL_CUSTOM_FUNC(call) = [](class_t& obj, const std::string& name) {
    auto func = (void(*)())obj.lookup(name);
    CHECK(func, "Cannot find function with name "<<name);
    try {
        func();
    }
    catch(const std::exception& e) {
        logERROR("Exception catched from JIT code: "<<e.what());
    }
    catch(...) {
        logERROR("Unknown exception catched from JIT code.");
    }
};

Puis en exécutant le script de test ci-dessus j’ai obtenu les résultats (corrects) suivants :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
[DEBUG]: Adding module from bytecode.
[DEBUG]: Calling dyn lib initialize()
[DEBUG]: Done calling dyn lib initialize()
Begin test.
I'm throwing an exception.
Catched exception: My exception message
End test.
[Error]         Exception catched from JIT code: Throwing from extern C :-)!
[DEBUG]: Deleting NervJIT object.
[DEBUG]: Uninitializing main JIT lib.
[DEBUG]: Done uninitializing main JIT lib.
[DEBUG]: Deleted NervJIT object.

XII. Test unitaires C++ scriptés – Seconde tentative

Après ces bons résultats avec la construction/destruction des globaux et la gestion des exceptions C++, j’ai pensé que je devrais réessayer mon système de tests unitaires scriptés. Et en fait, après des recherches supplémentaires, j’ai trouvé un environnement de test à entêtes seuls nommé Lest (cf. https://github.com/martinmoene/lest), il n’est pas aussi complexe/évolué que Catch2, mais dans ma situation c’était plutôt une bonne chose ! Parce que j’ai pu le faire fonctionner sans trop de problèmes dans mon code JIT :

 
Cacher/Afficher le codeSélectionnez

Le script ci-dessus a produit les résultats escomptés (avec les exceptions C++ activées évidemment) :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
[DEBUG]: Adding module from bytecode.
[DEBUG]: Calling dyn lib initialize()
[DEBUG]: Registering destructor in my AtExit override: 000001C804B8B490
[DEBUG]: Done calling dyn lib initialize()
Running session.
(21): failed: Text compares lexically (fail): string("hello") > string("world") for "hello" > "world"
(26): failed: got unexpected exception with message "surprise!": Unexpected exception is reported: (throw std::runtime_error("surprise!"), true)
(41): failed: didn't get exception: Expected exception is reported missing: true
(46): failed: didn't get exception of type std::runtime_error: Specific expected exception is reported missing: true
4 out of 7 selected tests failed.
lest detected 4 failing tests.
Done running session.
[Debug]               Done running tests.
[DEBUG]: Deleting NervJIT object.
[DEBUG]: Uninitializing main JIT lib.
[DEBUG]: Executing one at exit function.
[DEBUG]: Done executing one at exit function.
[DEBUG]: Done uninitializing main JIT lib.
[DEBUG]: Deleted NervJIT object.

XIII. Processus d’édition de liens des modules JIT

Maintenant, il y a une dernière chose dont j’aimerais parler dans cet article terriblement long : en essayant de construire des tests supplémentaires avec Lest, je voulais essayer le support d’« autoenregistrement du test » pour de multiples unités de traduction (cf. https://github.com/martinmoene/lest/blob/master/example/13-module-auto-reg-1.cpp). J’ai donc préparé trois scripts C++ séparés :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
// nv_land_test.cpp file
#define lest_FEATURE_AUTO_REGISTER 1
#include <test/lest.hpp>
#include <iostream>
#include <core_common.h>
 
#define TEST_CASE( name ) lest_CASE( specification(), name )
 
using namespace std;
 
lest::tests & specification()
{
    static lest::tests tests;
    return tests;
}
 
TEST_CASE( "Empty string has length zero (succeed)" )
{
    EXPECT( 0 == string(  ).length() );
    EXPECT( 0 == string("").length() );
 
    EXPECT_NOT( 0 < string("").length() );
}
extern "C" void nv_land_tests()
{
    char* argv[] = { "my_dummy_app.exe" };
    int argc = 1;
    DEBUG_MSG("Running session.");
    int res = lest::run( specification(), argc, argv /*, std::cout */  );
    if(res!=0) {
        DEBUG_MSG("lest detected "<<res<<" failing tests.");
    }
    DEBUG_MSG("Done running session.");
};
 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
// nv_land_1_spec.cpp
#define lest_FEATURE_AUTO_REGISTER 1
#include <test/lest.hpp>
#include <iostream>
#include <core_common.h>
 
#define TEST_CASE( name ) lest_CASE( specification(), name )
 
extern lest::tests & specification();
 
TEST_CASE( "A passing test" "[pass]" ) 
{
    EXPECT( 42 == 42 );
}
 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
// nv_land_2_spec.cpp
#define lest_FEATURE_AUTO_REGISTER 1
#include <test/lest.hpp>
#include <iostream>
#include <core_common.h>
 
#define TEST_CASE( name ) lest_CASE( specification(), name )
 
extern lest::tests & specification();
 
TEST_CASE( "A failing test" "[fail]" ) 
{
    EXPECT( 42 == 7 );
}

Puis j’aimerais essayer de charger ces trois fichiers en tant que « modules séparés » dans ma session JIT :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
local jit = import "base.JITCompiler"

jit:usePCHFile("")
jit:loadScript("test/nvland/nv_land_tests")
jit:loadScript("test/nvland/nv_land_1_spec")
jit:loadScript("test/nvland/nv_land_2_spec")

jit:execute("nv_land_tests")

logDEBUG("Done running tests.")

Mais bien sûr ça ne fonctionne pas, car aussitôt que j’essaie de charger le second script LLVM lève une erreur de duplication de symbole (et c’est parfaitement logique) :

[ERROR]: LLVM error: Duplicate definition of symbol '??_7success@lest@@6B@'

Donc je devais réellement comprendre comment faire pour lier ensemble plusieurs modules et corriger proprement ce type de problèmes de duplication de symboles. Et j’ai trouvé l’outil llvm-link qui semble fournir exactement cette prestation : il va nous permettre de prendre en entrée une série de modules et de les fusionner en un seul module, résolvant ainsi le problème comme nous le souhaitons.

À partir de là j’ai construit les fonctions auxiliaires suivantes (en utilisant les sources de llvm-link comme référence) :

 
Cacher/Afficher le codeSélectionnez

⇒ L’idée ci-dessus est d’utiliser l’objet llvm::Linker pour effectuer les étapes de liaison requises, puis d’écrire le module composite peuplé en un fichier bitcode avec la fonction WriteBitcodeToFile().

Puis j’implémente les fonctions frontales nécessaires en Lua pour utiliser les nouvelles caractéristiques de ce nouveau module :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
function Class:linkModule(files, force)
  -- For each file, we generate the bitcode:
  local bcfiles = {}
  for _,file in pairs(files) do 
    file = self:findFile(file)
    table.insert(bcfiles, self:buildBitcodeForFile(file, force))
  end

  local modHash = self:computeStringListHash(bcfiles)

  -- Now we should link the module and generate the corresponding hash:
  local modfile = self.bc_dir..modHash..".bc"

  if force or not nv.fileExists(modfile) then
    logDEBUG("Linking module file ", modfile, "...")
    local startTime = nv.SystemTime.getCurrentTime()
    self.jit:linkModule(modfile, bcfiles, true, true, true, true)
    local endTime = nv.SystemTime.getCurrentTime()
    logDEBUG(string.format("Linked module  in %.3fms", (endTime - startTime)*1000.0))
  end

  return modfile
end

function Class:loadModule(files, force)
  local modfile = self:linkModule(files, force)
  self.jit:loadModuleBitcode(modfile)
end

Et finalement, je peux mettre mon script de test à jour pour m’assurer que je puisse lier tous mes scripts C++ ensemble pour construire un module fonctionnel avant de le charger dans une session JIT :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
local jit = import "base.JITCompiler"

jit:usePCHFile("")
-- jit:loadScript("test/nvland/nv_land_tests")
-- jit:loadScript("test/nvland/nv_land_1_spec")
-- jit:loadScript("test/nvland/nv_land_2_spec")
jit:loadModule{
    "test/nvland/nv_land_tests",
    "test/nvland/nv_land_1_spec",
    "test/nvland/nv_land_2_spec",
}

jit:execute("nv_land_tests")

logDEBUG("Done running tests.")

Et cette fois, cela devrait fonctionner très bien :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
[DEBUG]: Adding module from bytecode.
[DEBUG]: Calling dyn lib initialize()
[DEBUG]: Done calling dyn lib initialize()
[Debug]               Running session.
(12): failed: A failing test[fail]: 42 == 7 for 42 == 7
1 out of 3 selected tests failed.
[Debug]               lest detected 1 failing tests.
[Debug]               Done running session.
[Debug]               Done running tests.

XIV. Conclusion

Bien, tout d’abord, je suis absolument désolé d’avoir fait un si long article… J’aurais sûrement dû le partager en plus petites sections :-S Mais nous y sommes quand même. Si vous êtes parvenu jusqu’ici, félicitations ! Et j’espère que vous trouverez des informations intéressantes et des idées dans le code et les détails décrits ici.

Comme d’habitude, au cas où cela puisse vous être utile vous trouverez ici un package contenant les sources C++ les plus récents pour ce compilateur NervJIT :

nv_llvm_20200427.zip (lien alternatif sur DVP)

Et pour moi il est temps de prendre un café et de faire une pause avant de passer à quelques tests de plus !

Bon codage à chacun !

XV. Remerciements Developpez.com

Ce tutoriel est la traduction de JIT Compiler with LLVM - Part 5 - Bitcode, PCH, exceptions handling, module linking and more…. 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.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

Licence Creative Commons
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.