Динамический JIT компилятор C++

bfp-nhjb78dua4_ix26ews1nqmi.jpeg

Когда-то давно я писал статью-наблюдение про использование Хабра в качестве площадки для архитектурных ревью технических решений практически любой сложности Хабр — ума палата, и продолжаю пользоваться этим способом на постоянной основе.

А теперь, похоже, я нашел еще один очень полезный Хаброхак, который заключается в использования Хабра как записной книжки, но не как в том анекдоте я думал, что это блокнот для заметок, а всем видно оказывается что я здесь написал, а самом хорошем смысле — для хранения результатов различных экспериментов, подведения итогов поиска в решения проблем и публикации итоговых выводов.

Так, на днях искал материалы про JIT компиляцию C++ в рантайме с помощью clang/llvm и поиск мне выдал мою же собственную статью трехгодичной давности: Динамическая JIT компиляция С/С++ в LLVM с помощью Clang / Хабр. В результате появилась уже новая статья с JIT компилятором C /С++, чуть больше, чем на 100 строк кода.


Предыстория

Мне для языка NewLang потребовался JIT компилятор С++ и сперва я попробовал воспользоваться примерами из первоисточника. У проекта clang есть исходники с примерами использования синтаксического анализатора кода, но все они ограничиваются анализом или обработкой синтаксического дерева, тогда как мне нужно получить из исходников С++ LLVM IR (низкоуровневое промежуточное представление).

В примерах llvm тоже есть файлы с различными примерами JIT компиляции и с динамическим созданием LLVM IR HowToUseLLJIT или HowToUseJIT, но это примеры для исполнения низкоуровневого кода и там нет преобразователя C++ → IR.

Попробовал взять cling. Но он использует собственный пропатченный форк llvm, который, естественно отстает от текущей актуальной версии. К тому же у меня так и не получилось его собрать. И после исправления пятой или шестой ошибки сборки я плюнул, и решил поискать что-то нибудь другое.

Другие найденные примеры были либо для старых версий LLVM и не собираются (эта известная проблема clang и LLVM), либо не то, что мне требуется.

В результате, как я написал выше, поисковик предложил мне мою же собственную статью на Хабре с примером генерации кода из C++ в IR с последующей компиляцией в JIT, который я и переделал под актуальную версию LLVM на основе примеров OrcV2. Но прежде, чем перейти непосредственно к коду, нужно рассказать про сам LLVM ORC.


LLVM ORC

LLVM ORC, это модульный API для создания JIT-компиляторов. Как написано в документации, существует несколько вариантов его использования:


  1. В учебных пособиях по LLVM используется простой JIT-класс на основе ORC для выполнения выражений, скомпилированных из игрушечного языка: калейдоскопа.
  2. Отладчик LLVM, LLDB, использует JIT кросс-компиляции для оценки выражений. В этом случае кросс-компиляция позволяет выполнять выражения, скомпилированные в процессе отладчика, в целевом процессе отладки, который может находиться на другом устройстве/архитектуре.
  3. В высокопроизводительных JIT (например, JVM, Julia), которые хотят использовать оптимизации LLVM в существующей JIT-инфраструктуре.
  4. В интерпретаторах и REPL, например. Cling (C++) и интерпретатор Swift.

Это вторая версия данного интерфейса и его основная «фишка» по сравнению с предыдущей — реализованная модель правил связывания и разрешения символов, которая применяется статическими и динамическими компоновщиками. Это позволяет ORC JIT выполнять произвольный LLVM IR, включая созданный обычным статическими компиляторами (например, clang), используя такие конструкции, как позднее связывание, видимость символов, слабые ссылки и т.д. Подробнее с примерами тут.


Переработанный код JIT компилятора C++ под LLVM 18 на ORCv2

Исходники кода можно взять тут либо развернуть спойлер.

#include 
#include 

#include 
#include 
#include 

#include 
#include 
#include 

#include 
#include 
#include 
#include 
#include 

using namespace llvm;
using namespace llvm::orc;

/* 
 * Функции и классы для вызова изнутри JIT
 */
namespace ns_stub {

    int func_stub(int arg1, short arg2) {
        return arg1*arg2;
    };

    int func_extern_stub() {
        return 4242;
    };

    class class_stub {
    public:
        int field_1;
        static int static_field_2;

        static class_stub * create(int a1, int a2) {
            return new class_stub(a1, a2);
        }

        class_stub() {
            printf("Call constructor class_stub()\n");
            field_1 = 0;
        }

        class_stub(int arg1, int arg2) {
            printf("Call constructor class_stub(%d, %d)\n", arg1, arg2);
            field_1 = arg1;
            static_field_2 = arg2;
        }

        virtual ~class_stub() {
            printf("Call virtual ~class_stub()\n");
        }

        int method_sum() {

            return field_1 + static_field_2;
        }

        int method_field1(int arg) {

            return field_1;
        }

        virtual double method_virt2() {

            return 999999999;
        }

        virtual float method_virt() {

            return 3.14 + field_1;
        }

        static float method_static() {
            return 3.1415;
        }
    };

    int class_stub::static_field_2 = 0;

    class class_full {
    public:

        class_full() {
        }

        int method() {
            return 42;
        }
    };
};

/*
 * Строка прототип для компиляции в JIT
 */
const char * func_text = ""
        "extern \"C\" int printf(const char *, ...);\n"
        "extern \"C\" int nv_add(int a, int b) {"
        "   printf(\"call nv_add(%d, %d)\\n\", a, b);"
        "   return a + b;"
        "};\n"
        ""
        "extern \"C\" int nv_sub(int a, int b) {"
        "   printf(\"call nv_sub(%d, %d)\\n\", a, b);"
        "   return a - b;"
        "};\n"
        "extern \"C\" int run(){"
        "   nv_add(100, 123);"
        "   nv_sub(100, 123);"
        "   return 42;"
        "};\n"
        ""
        "namespace ns_stub {"
        "   class run_internal {"
        "       public:\n"
        "       run_internal(){};"
        "       int method(){"
        "           return 43;"
        "       };"
        "   };"
        "   class class_full {"
        "       public:\n"
        "       class_full();"
        "       int method();"
        "   };"
        ""
        "   class class_stub {"
        "       public:\n"
        "       static class_stub * create(int, int);"
        "       class_stub();"
        "       class_stub(int arg1, int arg2);"
        "       int method_sum();"
        "       int method_field1(int);"
        "       virtual float method_virt();"
        "   };"
        ""
        "};"
        "extern \"C\" int run_internal(){"
        "   ns_stub::run_internal cl_int;"
        "   printf(\"run_internal.method %d\\n\", cl_int.method());"
        "   return 44;"
        "};\n"
        ""
        "extern \"C\" int run_stub(){"
        "   ns_stub::class_stub *cl = ns_stub::class_stub::create(123, 123);"
        "   printf(\"class_stub.method_sum %d\\n\", cl->method_sum());"
        "   delete cl;"
        "   return 42;"
        "};\n"
        ""
        "extern \"C\" int run_extern();"
        "extern \"C\" int run_extern_stub(){"
        "   return run_extern();"
        "};\n"
        "extern \"C\" int run_virt(){"
        "   ns_stub::class_stub *cl = ns_stub::class_stub::create(124, 125);"
        "   printf(\"class_stub.method_virt %f\\n\", cl->method_virt());"
        "   delete cl;"
        "   return 0;"
        "};\n"
        "";

#define DEBUG_MSG(msg) std::cout << "[DEBUG]: "< CompileCpp(std::string source) {
    clang::CompilerInstance compilerInstance;
    auto& compilerInvocation = compilerInstance.getInvocation();

    // Диагностика работы Clang
    clang::IntrusiveRefCntPtr DiagOpts = new clang::DiagnosticOptions;
    clang::TextDiagnosticPrinter *textDiagPrinter =
            new clang::TextDiagnosticPrinter(llvm::outs(), &*DiagOpts);

    clang::IntrusiveRefCntPtr pDiagIDs;

    clang::DiagnosticsEngine *pDiagnosticsEngine =
            new clang::DiagnosticsEngine(pDiagIDs, &*DiagOpts, textDiagPrinter);

    // Целевая платформа
    std::string triple = LLVMGetDefaultTargetTriple();

    std::vector itemstrs;
    itemstrs.push_back(triple.insert(0, "-triple="));
    itemstrs.push_back("-xc++");
    itemstrs.push_back("-std=c++20");
    //    itemstrs.push_back("-fno-exceptions");
    //    itemstrs.push_back("-funwind-tables");

    std::vector itemcstrs;
    for (unsigned idx = 0; idx < itemstrs.size(); idx++) {
        // note: if itemstrs is modified after this, itemcstrs will be full
        // of invalid pointers! Could make copies, but would have to clean up then...
        itemcstrs.push_back(itemstrs[idx].c_str());
        std::cout << itemcstrs.back() << "\n";
    }

    // Компиляция из памяти
    // Send code through a pipe to stdin
    int codeInPipe[2];
    pipe2(codeInPipe, O_NONBLOCK);
    write(codeInPipe[1], source.c_str(), source.size());
    close(codeInPipe[1]); // We need to close the pipe to send an EOF
    dup2(codeInPipe[0], STDIN_FILENO);

    itemcstrs.push_back("-"); // Read code from stdin

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

    auto& languageOptions = compilerInvocation.getLangOpts();
    auto& preprocessorOptions = compilerInvocation.getPreprocessorOpts();
    auto& targetOptions = compilerInvocation.getTargetOpts();

    auto& frontEndOptions = compilerInvocation.getFrontendOpts();
    //    frontEndOptions.ShowStats = true;

    auto& headerSearchOptions = compilerInvocation.getHeaderSearchOpts();
    //    headerSearchOptions.Verbose = true;

    auto& codeGenOptions = compilerInvocation.getCodeGenOpts();

    targetOptions.Triple = LLVMGetDefaultTargetTriple();
    compilerInstance.createDiagnostics(textDiagPrinter, false);

    DEBUG_MSG("Using target triple: " << triple);

    LLVMContextRef ctx = LLVMContextCreate();
    std::unique_ptr action = std::make_unique((llvm::LLVMContext *)ctx);

    assert(compilerInstance.ExecuteAction(*action));

    // Runtime LLVM Module
    std::unique_ptr module = action->takeModule();

    assert(module);

    // Оптимизация IR
    llvm::PassBuilder passBuilder;
    llvm::LoopAnalysisManager loopAnalysisManager;
    llvm::FunctionAnalysisManager functionAnalysisManager;
    llvm::CGSCCAnalysisManager cGSCCAnalysisManager;
    llvm::ModuleAnalysisManager moduleAnalysisManager;

    passBuilder.registerModuleAnalyses(moduleAnalysisManager);
    passBuilder.registerCGSCCAnalyses(cGSCCAnalysisManager);
    passBuilder.registerFunctionAnalyses(functionAnalysisManager);
    passBuilder.registerLoopAnalyses(loopAnalysisManager);
    passBuilder.crossRegisterProxies(loopAnalysisManager, functionAnalysisManager, cGSCCAnalysisManager, moduleAnalysisManager);

    llvm::ModulePassManager modulePassManager = passBuilder.buildPerModuleDefaultPipeline(llvm::OptimizationLevel::O3);
    modulePassManager.run(*module, moduleAnalysisManager);

    return module;
}

ExitOnError ExitOnErr;

ThreadSafeModule createDemoModule() {
    auto Context = std::make_unique();
    auto M = std::make_unique("test", *Context);

    // Create the add1 function entry and insert this entry into module M.  The
    // function will have a return type of "int" and take an argument of "int".
    Function *Add1F = Function::Create(FunctionType::get(Type::getInt32Ty(*Context),{Type::getInt32Ty(*Context)}, false),
    Function::ExternalLinkage, "add1", M.get());

    // Add a basic block to the function. As before, it automatically inserts
    // because of the last argument.
    BasicBlock *BB = BasicBlock::Create(*Context, "EntryBlock", Add1F);

    // Create a basic block builder with default parameters.  The builder will
    // automatically append instructions to the basic block `BB'.
    IRBuilder<> builder(BB);

    // Get pointers to the constant `1'.
    Value *One = builder.getInt32(1);

    // Get pointers to the integer argument of the add1 function...
    assert(Add1F->arg_begin() != Add1F->arg_end()); // Make sure there's an arg
    Argument *ArgX = &*Add1F->arg_begin(); // Get the arg
    ArgX->setName("AnArg"); // Give it a nice symbolic name for fun.

    // Create the add instruction, inserting it into the end of BB.
    Value *Add = builder.CreateAdd(One, ArgX);

    // Create the return instruction and add it to the basic block
    builder.CreateRet(Add);

    return ThreadSafeModule(std::move(M), std::move(Context));
}

int main(int argc, char *argv[]) {
    // Initialize LLVM.
    InitLLVM X(argc, argv);

    InitializeNativeTarget();
    InitializeNativeTargetAsmPrinter();

    cl::ParseCommandLineOptions(argc, argv, "HowToUseLLJIT");
    ExitOnErr.setBanner(std::string(argv[0]) + ": ");

    // Create an LLJIT instance.
    auto J = ExitOnErr(LLJITBuilder().create());
    //    auto M = createDemoModule();
    auto M = ThreadSafeModule(std::move(CompileCpp(func_text)), std::make_unique());

    std::string dump;
    llvm::raw_string_ostream err(dump);

    ExecutionSession &ES = J->getExecutionSession();

    //    JITDylib *plat = ES.getJITDylibByName("");
    //    assert(plat);
    //    dump.clear();
    //    plat->dump(err);
    //    std::cout << ":\n" << dump << "\n";
    //
    //    JITDylib *proc = ES.getJITDylibByName("");
    //    assert(proc);
    //    dump.clear();
    //    proc->dump(err);
    //    std::cout << ":\n" << dump << "\n";

    ExitOnErr(J->addIRModule(std::move(M)));

    // Функция с именем run_extern отсуствует (JIT session error: Symbols not found: [ run_extern ])
    // Подставим вместо нее указатель на другу функцию, но с таким же прототипом (func_extern_stub)
    const SymbolStringPtr Foo = ES.intern("run_extern");
    const ExecutorSymbolDef FooSym(ExecutorAddr::fromPtr(&ns_stub::func_extern_stub), llvm::JITSymbolFlags::Exported | llvm::JITSymbolFlags::Absolute);
    auto as = absoluteSymbols({
        {Foo, FooSym}
    });

    if (auto Err = J->getMainJITDylib().define(as)) {
        std::cout << "JD.define error: !\n";
        return 0;
    }

    Expected test = J->lookup("nv_add");
    if (!test) {
        std::cout << "lookup error:\n" << toString(test.takeError());
        return 0;
    }

    DEBUG_MSG("Retrieving nv_add/nv_sub functions...");

    auto addAddr = ExitOnErr(J->lookup("nv_add"));
    int (*add)(int, int) = addAddr.toPtr();
    assert(add);

    int res = add(40, 2);
    assert(42 == res);

    auto subAddr = ExitOnErr(J->lookup("nv_sub"));
    int (*sub)(int, int) = subAddr.toPtr();
    assert(sub);

    res = sub(50, 7);
    assert(43 == res);

    printf("Call: run_internal\n");
    auto run_internalAddr = ExitOnErr(J->lookup("run_internal"));
    int (*run_internal)() = run_internalAddr.toPtr();
    assert(run_internal);

    res = run_internal();
    assert(44 == res);

    // Линкер удаяет не используемый код, 
    // и если нет обращения к методу то его будет нельзя вызвать в JIT
    // JIT session error: Symbols not found: [ _ZN7ns_stub10class_stub6createEii, _ZN7ns_stub10class_stub10method_sumEv ]
    ns_stub::class_stub *cl = ns_stub::class_stub::create(0, 0);
    printf("Check run_stub.method %d\n", cl->method_sum());
    printf("Check run_stub.method_virt %f\n", cl->method_virt());
    delete cl;

    printf("Call: run_stub\n");
    auto run_stubAddr = ExitOnErr(J->lookup("run_stub"));
    int (*run_stub)() = run_stubAddr.toPtr();
    assert(run_stub);

    res = run_stub();
    assert(42 == res);

    printf("Call: run_extern_stub\n");
    auto run_extern_stubAddr = ExitOnErr(J->lookup("run_extern_stub"));
    int (*run_extern_stub)() = run_extern_stubAddr.toPtr();
    assert(run_extern_stub);

    res = run_extern_stub();
    assert(4242 == res);

    /*
     * 
     * Так нельзя !!!!! 
     * Виртуальные методы изнутри JIT вызываются неправильно при некорректном заголовочном файле!
     * 
     * ERROR !!!!
     * Virtual methods from within JIT are called incorrectly when the header file is incorrect!
     *      
     */

    printf("Call: run_virt\n");
    auto run_virtAddr = ExitOnErr(J->lookup("run_virt"));
    int (*run_virt)() = run_virtAddr.toPtr();
    assert(run_virt);

    res = run_virt();
    assert(0 == res);

    return 0;
}


Особенности работы JIT и ограничения

В исходном тексте я добавил комментарии, чтобы в будущем не забыть важные особенности и ограничения данной реализации, ведь это все таки тестовый пример, а не полноценный JIT компилятор C/C++.


  • Если в коде встречаются не разрешенные ссылки, но к ним нет обращения в скомпилированном коде, то все запустится и будет нормально работать даже не смотря на наличие не разрешенных символов.
  • Так как компоновщик во время оптимизации удаляет из финального файла функции к которым не было обращения, то JIT компилятор просто не сможет их найти, после чего выдаст сообщение об ошибке (не удалось разрешить символы). Причем это касается не только обычных функций, но и методов классов.

Ограничения при работе с классами С++:


  • Мне так и не удалось заставить JIT создать экземпляр класса, предоставив ему только прототип без реализации конструктора. Пришлось выкручиваться, создавая статический метод — фабрику экземпляров объектов.
  • При вызове виртуальных методов класса возникают ошибки (вызывается не та функция), что связано с тем, что если прототип класса отличается от скомпилированного в JIT, то вызов виртуального метода (который определяется индексом в таблице виртуальных методов), естественно будет отличатся от реального. Из-за этого могут возникать проблемы с отработкой вызова деструктора объектов при работе JIT.
  • Если прототип класса переданный в JIT отличается от реального, то к полям объекта лучше вообще не обращаться, так как в этом случае закрешиться легче легкого (обращения к полям происходит по смещению и если прототипы классов отличаются, то ой будет обязательно). Кстати, из подобного подхода получится JIT для хакеров исследователей безопасности :-)

© Habrahabr.ru