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

Kate

Administrator
Команда форума
-ti5p5kcsikrghmf-1px2ayno_4.png


При создании компилятора для собственного языка программирования я сделал его как транспайлер в исходный код на С++, вот только реализация сильно подкачала. Сначала приходится генерировать динамическую библиотеку с помощью вызова 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::eek:uts(), &*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 (т.к. githab.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. Дополнительно, я вставил пример компиляцию кода из оперативной памяти, найденный тут.

 
Сверху