Динамическая JIT компиляция С-С++ в LLVM с помощью Clang

в 6:55, , рубрики: C, c++, clang, jit, JIT-компилятор, LLVM, llvm ir, Блог компании TIMEWEB, Компиляторы, Программирование
Динамическая JIT компиляция С-С++ в LLVM с помощью Clang - 1

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

Из-за этого, я решил не откладывать перевод компилятора на использование LLVM, который планировался когда нибудь в будущем, а решил сделать это уже сейчас. И для этого нужно было научиться запускать компиляцию C++ кода с помощью библиотек Clang, но тут вылезло сразу несколько проблем.

Оказывается, интерфейс Clang меняется от версии к версии и все найденные мной примеры были старыми и не запускались в актуальной версии (Сlang 12), а стабильный C-style интерфейс предназначен для парсинга и анализа исходников и с помощью которого сгенерировать исполняемые файлы не получится*.

Дополнительная проблемой оказалось, что Clang не может анализировать файл из памяти, даже если для этого есть соответствующие классы. Из объяснений выходило, что в экземпляре компилятора проверяется, является ли ввод файлом**.

А теперь публикую результат своих изысканий в виде рабочего примера динамической компиляции С++ кода с последующей его загрузкой и выполнением скомпилированных функций. Исходники адаптированны под актуальную версию Clang 12. Пояснения к коду я перевел и дополнил перед публикацией, а ссылки на исходные материалы приведены в конце статьи.

  • *) Кажется в 14 версии планируется реализовать C интерфейс для генерации исполняемых файлов.
  • **) На самом деле, Clang может (или теперь может) компилировать файлы из оперативной памяти, поэтому в исходники я добавил и эту возможность.

Не простой LLVM

Как было написано в самом начале, интерфейс Clang меняется от версии к версии и работающий код, например для LLVM 7, может уже не работать для LLVM 8 или 6 (текущая актуальная версия 12.1 и на подходе уже 13 версия LLVM).

А стабильный C-style интерфейс libtooling предназначен для парсинга и создания AST, а не для генерации исполняемых файлов с помощью LLVM.

Поэтому, последовательность этапов получается следующая:

  • Распарсить исходный код С/С++ с правильными опциями и получить AST (Abstract Syntax Tree)
  • Преобразовать AST во внутреннее представление (Intermediate Representation).
  • Выполнить различные оптимизации и скомпилировать IR в исполняемый код (JIT LLVM).
  • Далее требуется создать экземпляр LLVM модуля, который хранит всю информацию о текущей среде выполнения.
  • И только затем можно будет загрузить скомпилированный код и переходить к непосредственному вызову функции, которую мы скомпилировали.

Необходимые пояснения для примера кода

Заголовочных файлов используется очень много, поэтому большинство из них вынесено в файл #include «llvm_precomp.h». Далее в отдельную функцию InitializeLLVM() вынесена инициализация LLVM.

Первое предостережение при использовании clang заключается в том, что он не может анализировать файл из памяти, даже если для этого есть классы. Причина в том, что в экземпляре компилятора он проверяет, является ли ввод файлом.

Первоначальный пример был сделан для Clang 6 или 7 версии, где такая возможность действительно отсутствовала, но сейчас это предостережение уже не актуально.

Во-вторых, это опции компиляции. Их нужно устанавливать таким же образом, как и в командной строке со всеми соответствующими флагами и включенными путями. Это можно сделать, позволив Сlang установить все автоматически, используя список аргументов по умолчанию.

Но самое главное, это диагностика проблем! Нужно начинать с настройки объектов, с помощью которых будут выводиться все предупреждения и ошибки в работе парсера Clang и всех последующих инструментов, необходимых для работы JIT компилятора для C/C++ кода.

Автор второй статьи (ссылки на исходные публикации приведены в конце) немного «причесал» исходный пример, т.к. ему пришлось заменить несколько unique_ptrs на контейнеры IntrusiveRefCntPtr, предоставленные LLVM (это было необходимо, поскольку исходный код не компилировался). Еще он добавил несколько дополнительных отладочных сообщений. Сейчас в примере динамически собираются две функции nv_add и nv_sub.

У меня тоже сразу не получилось использовать найденные примеры кода, т.к. интерфейс Clang опять поменялся и у некоторых функций, где раньше использовались обычные ссылки на объекты, они были заменены на IntrusiveRefCntPtr. Хотя в основном все осталось как в изначальных исходниках.

clang::IntrusiveRefCntPtr<clang::DiagnosticOptions> DiagOpts = new clang::DiagnosticOptions;
    clang::TextDiagnosticPrinter *textDiagPrinter = new clang::TextDiagnosticPrinter(llvm::outs(), &*DiagOpts);

clang::IntrusiveRefCntPtr<clang::DiagnosticIDs> pDiagIDs;
clang::DiagnosticsEngine *pDiagnosticsEngine = new clang::DiagnosticsEngine(pDiagIDs, &*DiagOpts, textDiagPrinter);

Возможно тут остался какой-то косяк, т.к. при завершении работы исходного примера, приложение падало с ошибками Segmentation fault или Double free or corrupt, но в конечном итоге методом «научного тыка» исходный код был приведен в состояние, когда пример завершается корректно.

Далее идет настройка Triple, комбинация из трех значений, которая определяет архитектуру процессора и целевую платформу. В моем случае это x86_64-pc-linux-gnu. После чего идет создание самого компилятора с опциями как в командной строке.

Сейчас Clang уже умеет парсить файлы из памяти, точнее из входного потока, и для этого во входных параметрах вместо имен файлов нужно передать минус, а сами данные записать в pipe:

    // Send code through a pipe to stdin
    int codeInPipe[2];
    pipe2(codeInPipe, O_NONBLOCK);
    write(codeInPipe[1], (void *) func_text, strlen(func_text));
    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

Далее в коде идет настройка опций компилятора и непосредственный вызов компилятора для создания AST.

if(!compilerInstance.ExecuteAction(*action)) {
}

Генерация исполняемого кода

Внимание, будьте аккуратны с контекстом выполнения!

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

Вторая проблема заключается в том, что по умолчанию не выполняется оптимизация IR. И это приходится выполнять вручную.

Первым делом получается модуль LLVM из предыдущего действия.

std::unique_ptr<llvm::Module> module = action->takeModule();
    if(!module) {
        ...
    }

После чего можно выполнять разные проходы оптимизации. Код для оптимизации довольно сложен, но это LLVM… и одна из причин, по которой API продолжает видоизменяться от версии к версии.


    llvm::PassBuilder passBuilder;
    llvm::LoopAnalysisManager loopAnalysisManager(codeGenOptions.DebugPassManager);
    llvm::FunctionAnalysisManager functionAnalysisManager(codeGenOptions.DebugPassManager);
    llvm::CGSCCAnalysisManager cGSCCAnalysisManager(codeGenOptions.DebugPassManager);
    llvm::ModuleAnalysisManager moduleAnalysisManager(codeGenOptions.DebugPassManager);

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

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

И только после этого можно использовать JIT-компилятор и искать в контексте нужную нам функцию. Имейте в виду, что модуль LLVM должен оставаться актуальным до тех пор, пока вы собираетесь используете скомпилированные данные!

    llvm::EngineBuilder builder(std::move(module));
    builder.setMCJITMemoryManager(std::make_unique<llvm::SectionMemoryManager>());
    builder.setOptLevel(llvm::CodeGenOpt::Level::Aggressive);
    auto executionEngine = builder.create();

    if(!executionEngine) {
        ...
    }

   reinterpret_cast<Function> (executionEngine->getFunctionAddress(function));

Исходники

Исходники проекта опубликованы на bitbucket (т.к. github.com хочет либо идентификацию с помощью внешних сервисов или настроить двухфакторную аутентификацию с помощью сертификатов). С первым не хочу заморачаться, а второе лень настраивать.

remote: Support for password authentication was removed on August 13, 2021. Please use a personal access token instead.
remote: Please see github.blog/2020-12-15-token-authentication-requirements-for-git-operations for more information.
fatal: недоступно: The requested URL returned error: 403

Сборка примера

Сборку исходников я проверял только под linux с установленным Clang 12. Система сборки используется от древней версии NetBeans, но все собирается стандартно с помощью команды make.

В статье использованы следующие материалы

Compiling C++ code in memory with clang и её переработка с небольшими исправлениями с учетом версии Clang. Еще добавил в пример компиляцию кода из оперативной памяти, который нашел тут.

З.Ы.

Собственно на этом все.

Пишите, если будут комментарии или замечания.

А вообще, настоящая JIT компиляция С++ кода, это очень круто!

Исходники примера JIT компилятора С/С++

#include <sstream>
#include <iostream>
#include <fstream>
#include <unistd.h>
#include <fcntl.h>

#include "llvm_precomp.h"


//#define NV_LLVM_VERBOSE 1

bool LLVMinit = false;

#define ERROR_MSG(msg) std::cout << "[ERROR]: "<<msg<< std::endl;
#define DEBUG_MSG(msg) std::cout << "[DEBUG]: "<<msg<< std::endl;

void InitializeLLVM() {
    if(LLVMinit) {
        return;
    }

    // We have not initialized any pass managers for any device yet.
    // Run the global LLVM pass initialization functions.
    llvm::InitializeNativeTarget();
    llvm::InitializeNativeTargetAsmPrinter();
    llvm::InitializeNativeTargetAsmParser();

    auto& Registry = *llvm::PassRegistry::getPassRegistry();

    llvm::initializeCore(Registry);
    llvm::initializeScalarOpts(Registry);
    llvm::initializeVectorization(Registry);
    llvm::initializeIPO(Registry);
    llvm::initializeAnalysis(Registry);
    llvm::initializeTransformUtils(Registry);
    llvm::initializeInstCombine(Registry);
    llvm::initializeInstrumentation(Registry);
    llvm::initializeTarget(Registry);


    LLVMinit = true;
}

int main(int argc, char *argv[]) {

    InitializeLLVM();

    const char * func_text = 
"int nv_add(int a, int b) {n
    printf("call nv_add(%d, %d)\n", a, b);n
    return a + b;n
}n
n
int nv_sub(int a, int b) {n
    printf("call nv_sub(%d, %d)\n", a, b);n
    return a - b;n
}n
";


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


    clang::CompilerInstance compilerInstance;
    auto& compilerInvocation = compilerInstance.getInvocation();


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

    clang::IntrusiveRefCntPtr<clang::DiagnosticIDs> pDiagIDs;

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



    // Целевая платформа
    std::stringstream ss;
    ss << "-triple=" << llvm::sys::getDefaultTargetTriple();
    
    std::cout << llvm::sys::getDefaultTargetTriple();
    
    std::istream_iterator<std::string> begin(ss);
    std::istream_iterator<std::string> end;
    std::istream_iterator<std::string> i = begin;
    std::vector<const char*> itemcstrs;
    std::vector<std::string> itemstrs;
    while(i != end) {
        itemstrs.push_back(*i);
        ++i;
    }

    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());
    }

    // Компиляция из памяти
    // Send code through a pipe to stdin
    int codeInPipe[2];
    pipe2(codeInPipe, O_NONBLOCK);
    write(codeInPipe[1], (void *) func_text, strlen(func_text));
    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<const char *>(itemcstrs.data(), itemcstrs.size()), *pDiagnosticsEngine);

    auto* languageOptions = compilerInvocation.getLangOpts();
    auto& preprocessorOptions = compilerInvocation.getPreprocessorOpts();
    auto& targetOptions = compilerInvocation.getTargetOpts();
    auto& frontEndOptions = compilerInvocation.getFrontendOpts();
#ifdef NV_LLVM_VERBOSE
    frontEndOptions.ShowStats = true;
#endif
    auto& headerSearchOptions = compilerInvocation.getHeaderSearchOpts();
#ifdef NV_LLVM_VERBOSE
    headerSearchOptions.Verbose = true;
#endif
    auto& codeGenOptions = compilerInvocation.getCodeGenOpts();


    targetOptions.Triple = llvm::sys::getDefaultTargetTriple();
    compilerInstance.createDiagnostics(textDiagPrinter, false);

    llvm::LLVMContext context;
    std::unique_ptr<clang::CodeGenAction> action = std::make_unique<clang::EmitLLVMOnlyAction>(&context);

    if(!compilerInstance.ExecuteAction(*action)) {
        ERROR_MSG("Cannot execute action with compiler instance.");
    }

    // Runtime LLVM Module
    std::unique_ptr<llvm::Module> module = action->takeModule();
    if(!module) {
        ERROR_MSG("Cannot retrieve IR module.");
    }

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

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

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

    llvm::EngineBuilder builder(std::move(module));
    builder.setMCJITMemoryManager(std::make_unique<llvm::SectionMemoryManager>());
    builder.setOptLevel(llvm::CodeGenOpt::Level::Aggressive);

    std::string createErrorMsg;

    builder.setEngineKind(llvm::EngineKind::JIT);
    builder.setVerifyModules(true);
    builder.setErrorStr(&createErrorMsg);

    std::string triple = llvm::sys::getDefaultTargetTriple();
    DEBUG_MSG("Using target triple: " << triple);
    auto executionEngine = builder.create();

    if(!executionEngine) {
        ERROR_MSG("Cannot create execution engine.'" << createErrorMsg << "'");
    }

    DEBUG_MSG("Retrieving nv_add/nv_sub functions...");
    typedef int(*AddFunc)(int, int);
    typedef int(*SubFunc)(int, int);

    AddFunc add = reinterpret_cast<AddFunc> (executionEngine->getFunctionAddress("nv_add"));
    if(!add) {
        ERROR_MSG("Cannot retrieve Add function.");
    } else {
        int res = add(40, 2);
        DEBUG_MSG("The meaning of life is: " << res << "!");
    }

    SubFunc sub = reinterpret_cast<SubFunc> (executionEngine->getFunctionAddress("nv_sub"));
    if(!sub) {
        ERROR_MSG("Cannot retrieve Sub function.");
    } else {
        int res = sub(50, 8);
        DEBUG_MSG("The meaning of life is really: " << res << "!");
    }

    DEBUG_MSG("Done running clang compilation.");

    return 0;
}

Динамическая JIT компиляция С-С++ в LLVM с помощью Clang - 2

Автор: Александр Рябиков

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js