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

от автора

При создании компилятора для собственного языка программирования я сделал его как транспайлер в исходный код на С++, вот только реализация сильно подкачала. Сначала приходится генерировать динамическую библиотеку с помощью вызова 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 компиляцию C++ с использованием интерфейса ORCv2.

А вообще, настоящая 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; } 


ссылка на оригинал статьи https://habr.com/ru/articles/572878/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *