Як зробити з Ninja систему розподіленої збірки?

Привіт, Хабр!

Нещодавно я задумався, колупаючи чергову безкоштовну систему складання, «А чи не можна взяти і самому написати таку систему? Адже це просто — взяти ту ж Ninja, прикрутити поділ на препроцессинг і компіляцію, так і передавати по мережі файли туди-сюди. Куди вже простіше?»

Просто — не просто, як самому зробити подібну систему — розповім під катом.

Етап 0. Формулювання завдання
Disclaimer: Стаття відзначена як tutorial, але це не зовсім покрокове керівництво, скопипастив код з якого вийде готовий продукт. Це скоріше інструкція — як спланувати і куди копати.

Спершу визначимося, який загальний алгоритм роботи повинен вийти:

  • Читаємо граф складання, виокремлює команди компіляції;
  • Розбиваємо компіляцію на два етапи, препроцессинг і власне генерацію коду. Останню помічаємо як можливу до віддаленого виконання;
  • Виконуємо препроцессинг, зчитуємо результат в пам'ять;
  • Відправляємо препроцессированный файл команду на генерацію коду на інший хост по мережі;
  • Виконуємо команду кодогенерации, зчитуємо об'єктний файл і віддаємо в якості відповіді по мережі;
  • Отриманий об'єктний файл зберігаємо на диск і виводимо на консоль повідомлення компілятора.
Начебто не так і страшно, вірно? Але відразу за вечір написати все це, мабуть, не вийде. Спочатку напишемо декілька прототипів, і стаття розповідає про них:

  1. Прототип 1. Програма імітує компілятор, розділяючи команду на 2, і самостійно викликаючи компілятор.
  2. Прототип 2. До цього додамо пересилку команди на компіляцію по мережі, без самого файлу.
  3. Прототип 3. Пройдемося по графу складання Ninja, виводячи потенційно розбиваються команди.
Рекомендується розробку прототипу робити під POSIX-сумісної OS, якщо ви не будете користуватися бібліотеками.

Етап 1. Розбиваємо командний рядок
Для прототипу зупинимося на компіляторі GCC (або Clang, немає великої різниці), т. до. його командний рядок простіше розбирати.

Нехай у нас програма викликається через команду «test -c hello.cpp -o hello.o». Будемо вважати, що після ключа "-c" (компіляція в об'єктний код) завжди йде ім'я вхідного файлу, хоч це і не так. Так само поки що зупинимося тільки на роботі локальної директорії.

Ми будемо використовувати функцію popen для запуску процесу і отримання стандартного виводу. Функція дозволяє відкрити процес так само, як ми б відкрили файл.

Файл main.cpp:

#include < iostream>

#include "InvocationRewriter.hpp"
#include "LocalExecutor.hpp"

int main(int argc, char ** argv)
{
StringVector args;
for (int i = 1; i < argc; ++i)
args.emplace_back(argv[i]);

InvocationRewriter rewriter;
StringVector ppArgs, ccArgs; // аргументи для препроцессинга і компіляції відповідно.
if (!rewriter.SplitInvocation(args, ppArgs, ccArgs))
{
std::cerr << "Usage: -c <filename> -o <filename> \n";
return 1;
}

LocalExecutor localExecutor;
const std::string cxxExecutable = "/usr/bin/g++"; // припускаємо, що ми працюємо під GNU/Linux.
const auto ppResult = localExecutor.Execute(cxxExecutable, ppArgs);
if (!ppResult.m_result)
{
std::cerr << ppResult.m_output;
return 1;
}

const auto ccResult = localExecutor.Execute(cxxExecutable, ccArgs);
if (!ccResult.m_result)
{
std::cerr << ccResult.m_output;
return 1;
}
// не враховано варіант, що є стандартний вивід, але успішний результат.

return 0;
}


Код InvocationRewriter.hpp
#pragma once

#include < string>
#include < vector>
#include < algorithm>

using StringVector = std::vector<std::string>;

class InvocationRewriter
{
public:
bool SplitInvocation(const StringVector & original,
StringVector & preprocessor,
StringVector & kde)
{
// Знайдемо спершу позиції аргументів -c -o.
// Будемо вважати, що після -c завжди йде ім'я вхідного файлу, хоч це і не так.
const auto cIter = std::find(original.cbegin(), original.cend(), "-c");
const auto oIter = std::find(original.cbegin(), original.cend(), "-o");
if (cIter == original.cend() || oIter == original.cend())
return false;

const auto cIndex = cIter - original.cbegin();
const auto oIndex = oIter - original.cbegin();
preprocessor = compilation = original;

const std::string & inputFilename = original[cIndex + 1];
preprocessor[oIndex + 1] = "pp_" + inputFilename; // абсолютні імена не підтримуються
preprocessor[cIndex] = "-E"; // замість компіляції - препроцессинг.

compilation[cIndex + 1] = "pp_" + inputFilename;
return true;
}
};



Код LocalExecutor.hpp
#pragma once

#include < string>
#include < vector>
#include < algorithm>

#include < stdio.h>

using StringVector = std::vector<std::string>;

class LocalExecutor
{
public:
/// Результат виконання команди: стандартний вивід + результат
struct ExecutorResult
{
std::string m_output;
bool m_result = false;
ExecutorResult(const std::string & output = "", bool result = false)
: m_output(output), m_result(result) {}
};

/// виконує команду з допомогою popen.
ExecutorResult Execute(const std::string & executable, const StringVector & args)
{
std::string cmd = executable;
for (const auto & arg : args)
cmd += " " + arg;
cmd += " 2>&1"; // об'єднаємо sterr і stdout.

FILE * process = popen(cmd.c_str(), "r");
if (!process)
return ExecutorResult("Failed to execute:" + cmd);

ExecutorResult result;
char buffer[1024];
while (fgets(buffer, sizeof(buffer)-1, process) != nullptr)
result.m_output += std::string(buffer);

result.m_result = pclose(process) == 0;
return result;
}
};



Що ж, тепер у нас є маленький емулятор компілятора, який смикає справжній компілятор. Їдемо далі :)

Подальший розвиток прототипу:

  • Враховувати абсолютні імена файлів;
  • Використовувати одну з бібліотек для роботи з процесами: Boost.Process, QProcess, або Ninja Subprocess;
  • Реалізувати підтримку поділу команд для MSVC;
  • Зробити API для виконання команд асинхронним, а виконання винести в окремий потік.
Етап 2. Мережева підсистема
Прототип мережевого обміну зробимо на BSD Sockets (Сокетів Берклі

Трохи теорії:

Сокет це дослівно «дірка», в яку можна писати і зчитувати дані з неї. Щоб підключитися до віддаленого сервера, алгоритм наступний:

  • Створити сокет потрібного типу (TCP) з допомогою функції socket();
  • Після створення, виставити потрібні прапори, наприклад неблокуючий режим з допомогою setsockopt();
  • Отримати адресу в потрібному форматі для BSD сокетів з допомогою getaddrinfo();
  • підключення до TCP-хосту з допомогою функції connect(), передавши туди підготовлений адресу;
  • Викликати функції read/send для читання і запису;
  • Після закінчення роботи — викликати close().
Сервер працює трохи складніше:

  • Створюємо сокет з допомогою функції socket();
  • Виставляємо опції;
  • Викликаємо bind() для того, щоб прив'язати сокет до певного адресою (отриманим через getaddrinfo)
  • Починаємо прослуховування порту з допомогою виклику listen();
  • Вхідні з'єднання примаем функцією accept() — повертає нам новий сокет;
  • З отриманим сокетом виконуємо операції read/write;
  • Закриваємо сокет з'єднання і сокет прослуховування через close().
Нам знадобляться сокет-клієнт і сокет-сервер. Нехай їх інтерфейс виглядає наступним чином:

/// Інтерфейс сокету
class IDataSocket
{
public:
using Ptr = std::shared_ptr<IDataSocket>;
/// Результати читання та запаси. Success - Успішно, TryAgain - дані не були прочитані або записані, Fail - сокет був закритий.
enum class WriteState { Success, TryAgain, Fail };
enum class ReadState { Success, TryAgain, Fail };

public:
virtual ~IDataSocket() = default;

/// Підключення до віддаленого хосту
віртуальний bool Connect () = 0;

/// Закриття з'єднання
virtual void Disconnect () = 0;

/// Перевірки статусу з'єднання - підключений; зараз підключається
віртуальний bool IsConnected () const = 0;
віртуальний bool IsPending() const = 0;

/// Читаємо дані з сокета в буфер
virtual ReadState Read(ByteArrayHolder & buffer) = 0;

/// Пишемо дані в сокет.
virtual WriteState Write(const ByteArrayHolder & buffer, size_t maxBytes = size_t(-1)) = 0;
};

/// інтерфейс "слухача". Він може створювати сокети при підключенні.
class IDataListener
{
public:
using Ptr = std::shared_ptr<IDataListener>;
virtual ~IDataListener() = default;

/// Отримання наступного з'єднання
virtual IDataSocket::Ptr GetPendingConnection() = 0;

/// Початок прослуховування порту:
віртуальний bool StartListen() = 0;
};

Реалізацію цього інтерфейсу я не буду вставляти в статтю, ви можете її зробити самостійно або підглянути ось тут.

Припустимо, сокет у нас готовий, як буде виглядати приблизно клієнт і сервер компілятора?

Сервер:

#include <TcpListener.h>

#include < algorithm>
#include < iostream>

#include "LocalExecutor.hpp"

int main()
{
// Створимо налаштування для підключення.
TcpConnectionParams tcpParams;
tcpParams.SetPoint(6666, "localhost");

// Створимо прослуховування порту 6666;
auto listener = TcpListener::Create(tcpParams);
IDataSocket::Ptr connection;

// Дочекаємося першого вхідного з'єднання;
while((connection = listener->GetPendingConnection()) == nullptr) ;

// Підключимо з'єднання і прочитаємо всі дані.
connection->Connect();
ByteArrayHolder incomingBuffer; //!< просто обгортка над std::vector<uint8_t>;
while (connection->Read(incomingBuffer) == IDataSocket::ReadState::TryAgain) ;

// Винятком, що в якості даних нам прийшла команда, виконаємо її.
std::string args((const char*)(incomingBuffer.data()), incomingBuffer.size());
std::replace(args.begin(), args.end(), '\n', ' ');
LocalExecutor localExecutor;
const auto result = localExecutor.Execute("/usr/bin/g++", StringVector(1, args));
std::string stdOutput = result.m_output;
if (stdOutput.empty())
stdOutput = "OK\n"; // невеликий хак - якщо результат виконання порожній, відправимо хоча б ОК.

// запишемо в підключився сокет результат виконання команди.
ByteArrayHolder outgoingBuffer;
std::copy(stdOutput.cbegin(), stdOutput.cend(), std::back_inserter(outgoingBuffer.ref()));
connection->Write(outgoingBuffer);

connection->Disconnect();

// Можна не виходити тут, а винести обробку сполук в окремий потік.
// А потім і обробку читання/запису з кожного підключення в окремий потік.
return 0;
}


Клієнт:

#include < iostream>

#include <TcpSocket.h>

#include "InvocationRewriter.hpp"
#include "LocalExecutor.hpp"

int main(int argc, char ** argv)
{
StringVector args;
for (int i = 1; i < argc; ++i)
args.emplace_back(argv[i]);

InvocationRewriter rewriter;
StringVector ppArgs, ccArgs; // аргументи для препроцессинга і компіляції відповідно.
if (!rewriter.SplitInvocation(args, ppArgs, ccArgs))
{
std::cerr << "Usage: -c <filename> -o <filename> \n";
return 1;
}

LocalExecutor localExecutor;
const std::string cxxExecutable = "/usr/bin/g++"; // припускаємо, що ми працюємо під GNU/Linux.
const auto ppResult = localExecutor.Execute(cxxExecutable, ppArgs);
if (!ppResult.m_result)
{
std::cerr << ppResult.m_output;
return 1;
}

// Підключимося до сервера на порт 6666
TcpConnectionParams tcpParams;
tcpParams.SetPoint(6666, "localhost");
auto connection = TcpSocket::Create(tcpParams);
connection->Connect();

ByteArrayHolder outgoingBuffer;
for (auto arg : ccArgs)
{
arg += " "; // розділимо аргументи пробілом і вставимо в буфер.
std::copy(arg.cbegin(), arg.cend(), std::back_inserter(outgoingBuffer.ref()));
}

connection->Write(outgoingBuffer);

ByteArrayHolder incomingBuffer;
while (connection->Read(incomingBuffer) == IDataSocket::ReadState::TryAgain) ;

std::string response((const char*)(incomingBuffer.data()), incomingBuffer.size());
if (response != "OK\n")
{
std::cerr << response;
return 1;
}

return 0;
}

Так, не всі вихідні коди показано, наприклад TcpConnectionParams або ByteArrayHolder, але це досить примітивні структури.

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

Подальший розвиток прототипу:

  • Настійно рекомендую використовувати одну з існуючих мережевих бібліотек Boost.Asio, QTcpSocket (QtNetwork), так само подумати над серіалізацією з допомогою Protobuf або інших подібних
  • Реалізувати передачу файлів по мережі. Швидше за все, доведеться розбивати на фрагменти, але буде залежати від обраної вами бібліотеки.
  • Необхідно задуматися про асинхронному API для відправки і прийому повідомлень. Крім того, бажано зробити його абстрактним і не прив'язаним до сокетам взагалі.
Етап 3. Інтеграція з Ninja
Для початку, необхідно ознайомитися з принципами роботи Ninja. Передбачається, що ви вже збирали з її допомогою будь-які проекти і приблизно уявляєте, як виглядає build.ninja.
Використовувані поняття:

  • Вузол (Node) — це просто файл. Вхідний (исходники), вихідний (об'єктні файли) — це всі вузли або вершини графа.
  • Правило (Rule) — по суті це просто команда з шаблоном аргументів. Наприклад, виклик gcc — правило, а його аргументи — $FLAGS $INCLUDES $DEFINES і ще якісь загальні аргументи.
  • Ребро (Edge). Для мене було трохи дивно, але ребро з'єднує не два вузла, а кілька вхідних вузлів і один вихідний, за допомогою Правила. Вся система складання заснована на тому, що послідовно обходить граф, виконуючи команди для ребер. Як тільки всі ребра оброблені, проект зібраний.
  • Стан (State) — це контейнер з усім перерахованим вище, що система збирання і використає.
Як це приблизно виглядає, якщо намалювати залежності:



Тут показаний граф збірки для двох одиниць трансляції, які компонуються в додаток.

Як ми бачимо, для того, щоб внести свої зміни в систему збирання, нам потрібно переписати State, розбивши Edges на два в потрібних місцях і додавши нові вузли (препроцессированные файли).
Припустимо, у нас вже є исходники ninja, ми їх збираємо, і все в зібраному вигляді працює.
Додамо в ninja.cc наступний фрагмент коду:

// Limit number of rebuilds, to prevent infinite loops.
const int kCycleLimit = 100;
for (int cycle = 1; cycle <= kCycleLimit; ++cycle) {
NinjaMain ninja(ninja_command, config);

ManifestParser parser(&ninja.state_, &ninja.disk_interface_,
options.dupe_edges_should_err
? kDupeEdgeActionError
: kDupeEdgeActionWarn);
string err;
if (!parser.Load(options.input_file, &err)) {
Error("%s", err.c_str());
return 1;
}
// граф складання вже завантажений, тепер модифікуємо його:
RewriteStateRules(&ninja.state_); // ось цей виклик

Саму функцію RewriteStateRules можна винести в окремий файл, або оголосити тут же, в ninja.cc як:

#include "InvocationRewriter.hpp"

// Структура, яка описує заміну правил Ninja.
struct RuleReplace
{
const Rule* pp;
const Rule* cc;
std::string toolId;
RuleReplace() = default;
RuleReplace(const Rule* pp_, const Rule* cc_, std::string id) : pp(pp_), cc(cc_), toolId(id) {}
};

void RewriteStateRules(State *state)
{
// скопіюємо поточні правила, т. к. буде не дуже добре, коли ми будемо модифікувати цей контейнер.
const auto rules = state->bindings_.GetRules();
std::map<const Rule*, RuleReplace> ruleReplacement;
InvocationRewriter rewriter;
// пройдемо по всім існуючим правилам
for (const auto & ruleIt : rules)
{
const Rule * rule = ruleIt.second;
const EvalString* command = rule->GetBinding("command");
if (!command) continue;
// сформуємо команду для нашого rewriter-а.
std::vector<std::string> originalRule;
for (const auto & strPair : command->parsed_)
{
std::string str = strPair.first;
if (strPair.second == EvalString::SPECIAL)
str = '$' + str;
originalRule.push_back(str);
}
// спробуємо розділити команду:
std::vector<std::string> preprocessRule, compileRule;
if (rewriter.SplitInvocation(originalRule, preprocessRule, compileRule))
{
// створимо 2 копії rule - rulePP і ruleCC, замінимо їх bindings_ на нові команди.
// занесемо нові правила в ruleReplacement (ruleReplacement[rule] = ...)
}
}

const auto paths = state->paths_;
std::set<Edge*> erasedEdges;
// пройдемо по всіх вузлах графа
for (const auto & iter : paths)
{
Node* node = iter.second;
Edge* in_egde = node->in_edge();
if (!in_egde)
continue;
// отримаємо вхідна ребро і відповідне правило.
// якщо правило необхідно розбити на два, зробимо це:
const Rule * in_rule = &(in_egde->rule());
auto replacementIt = ruleReplacement.find(in_rule);
if (replacementIt != ruleReplacement.end())
{
RuleReplace replacement = replacementIt->second;
const std::string objectPath = node->path();
const std::string sourcePath = in_egde->inputs_[0]->path();
const std::string ppPath = sourcePath + ".pp"; // краще зробити окремий метод для імен файлів.
Node *pp_node = state->GetNode(ppPath, node->slash_bits());

// Створимо два нових ребра
Edge* edge_pp = state->AddEdge(replacement.pp);
Edge* edge_cc = state->AddEdge(replacement.cc);

// ... код пропущено ...
// помістимо входи вихідного ребра під входи edge_pp;
// виходи вихідного ребра в виходи edge_cc
// а в середині вставимо pp_node.

// крім того, особливо відзначимо edge_cc, що може виконуватися дистанційно -
// наприклад, додавши полі:
edge_cc->is_remote_ = true;

// після всіх маніпуляцій, очистимо вихідне ребро.
in_egde->outputs_.clear();
in_egde->inputs_.clear();
in_egde->env_ = nullptr;
erasedEdges.insert(in_egde);
}
}
// видаляємо зайві ребра.
vector<Edge*> newEdges;
for (auto * edge : state->edges_)
{
if (erasedEdges.find(edge) == erasedEdges.end())
newEdges.push_back(edge);
}
state->edges_ = newEdges;
}

Деякі нудні вирізані фрагменти, повний код можна подивитися тут.

Доробка прототипу:

  • Швидше за все, перший варіант InvocationRewriter не запрацює, потрібно буде враховувати багато речей — наприклад, те, що аргумент компіляції "-c" може бути заданий " -c ", ну і я вже мовчу про те що він не обов'язково передує вихідний файл.
  • Може бути багато додаткових прапорів, які відзначають якісь файли, так що не все те, що «не прапор» — це файл.
  • Після створення розділеного графа, якщо він успішно збирається в дві фази «препроцессинг і компіляція» — потрібно буде проінтегрувати віддалене виконання по мережі з нашим мережевим шаром. Власне цикл збірки в Ninja знаходиться в build.cc у функції Builder::Build. У неї можна додати за аналогією з
    «if (failures_allowed && command_runner_->CanRunMore())» і «if (pending_commands)» свої етапи для розподіленої складання.
Етап X. Що далі?
Після успішного створення прототипу, потрібно рухатися маленькими кроками до створення продукту:

  • Конфігурування всіх модулів — як мережевої підсистеми, так і InvocationRewriter-а;
  • Підтримка будь-яких комбінацій опцій під різними компіляторами;
  • Підтримка стиснення при передачі файлів;
  • Різноманітна діагностика у вигляді логів;
  • Написання координатора, який зможе обслуговувати підключення до декількох серверів складання;
  • Написання балансувальника, який буде враховувати те, що серверами користується відразу кілька клієнтів (і не перевантажувати їх надміру);
  • Написати інтеграцію з іншими системами збірки, не тільки Ninja.
Загалом, хлопці, я зупинився десь на цьому етапі; зробив OpenSource-проект Wuild (исходники тут), ліцензія Apache, який всі ці штуки реалізує. На написання пішло приблизно 150 годин вільного часу (коли хтось зважиться повторити мій шлях). Я настійно рекомендую по максимуму використовувати існуючі вільні бібліотеки, щоб сконцентруватися на бізнес-логікою і не налагоджувати роботу мережі або запуск процесів.

Що вміє Wuild:

  • Розподілена збірка з можливістю крос-компіляції (Clang) під Win, Mac, Linux;
  • Інтеграція з Ninja та Make.
Та загалом і все; проект в стані між альфою і бетою (стабільність — фіч — ні :D ). Бенчмарків не викладаю (рекламувати не хочу), але, в порівнянні з одним із продуктів-аналогом, швидкість мене більш ніж влаштувала.

Стаття носить скоріше освітній характер, а проект — предостерегательный (як робити не треба, в розумінні NIH-синдрому — робіть менше велосипедів).

Хто хоче — форкайте, робіть пулл-реквесты, використовувати у будь-яких страшних цілях!
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.