bmstu-iu9 / refal-5-lambda

Компилятор Рефала-5λ
https://bmstu-iu9.github.io/refal-5-lambda
Other
79 stars 35 forks source link

Рассмотреть переход с C++ на C и сделать выводы #197

Open Mazdaywik opened 5 years ago

Mazdaywik commented 5 years ago

Эта задача — подзадача для #185.

Гипотеза: на языке Си получаются более эффективные программы, чем на Си++. Предпосылки.

Поэтому ради повышения быстродействия разумно рассмотреть вариант переписывания на Си. И посмотреть, к каким следствиям это приведёт.

Очевидные изменения

Очевидно, что в рантайме и Library придётся отказаться от классов, пространств имён и стандартной библиотеки Си++.

Классы придётся заменять на структуры, методы — на функции, принимающие указатели на класс. Пространства имён придётся заменять префиксами.

Вместо контейнеров стандартной библиотеки придётся писать свои велосипеды, либо делать интруизивные контейнеры — просто предусматривать, что объекты могут объединяться в список и добавлять для этого поля вроде next.

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

Но это всё по большей части тривиальный рефакторинг, не вносящий содержательную сложность (но, по опыту разработки Рефала-05, довольно увлекательный).

Будет два очевидных положительных последствия:

Нетривиальные изменения

Нетривиальной здесь будет компиляция файлов с нативным кодом. Сейчас она завязана на вызов конструкторов глобальных объектов — возможность, которая есть в стандартном Си++ и которой нет в стандартном Си (есть непереносимые средства в различных реализациях).

Что у нас есть

Текущее представление развивалось эволюционно, путём постепенного переноса части знаний из Си++ в RASL, внесения реентерантности и т.д. Поэтому (как дальше будет видно), оно кривоватое.

Сгенерированный файл можно условно разбить на несколько частей:

Все эти объекты — IdentReference, ExternalReference, NativeReference, GlobalRef<> полагаются на свои конструкторы для инициализации.

При этом, рантайм у нас реентерантный (#163). А это значит, что два независимых домена могут загрузить один и тот же модуль, и значения, доступные через эти переменные, должны быть в каждом домене свои. Эти значения хранятся в доменах, а в IdentReference, ExternalReference и GlogalRef находятся только смещения для внутреннего массива.

А что же делают глобальные конструкторы. Для типов *Reference они провязывают их в односвязный список, голову помещают в глобальную переменную. Для первых двух сущностей устанавливается глобальный счётчик. Для GlobalGen<> вычисляется смещение этой переменной во внутримодульной памяти. После инициализации эти объекты более не меняются. Результаты их инициализации (головы списков, максимумы счётчиков) сохраняются в глобальной структуре NativeModule g_module — собственно, конструкторы кладут себя на голову списка и увеличивают счётчики в этой структуре.

При загрузке модуля в смысле Рефала (#167) по содержимому g_module инициализируются вектора для идентификаторов и внешних ссылок, из списка нативных функций по именам извлекаются указатели на них, для глобальных переменных выделяется соответствующий объём памяти. Модуль может быть загружен в несколько доменов — соответствующие вектора везде будут свои, но индексы одни и те же.

К идентификаторам, внешним функциям и глобальным переменным приходится обращаться вызовом вида object.ref(vm), для того, чтобы обратиться к соответствующему массиву, доступному внутри виртуальной машины. NativeReference в пользовательском коде не вызываются.

Все эти объекты возникли эволюционно — сначала они содержали реальные данные, а вместо GlobalRef<> были обычные глобальные переменные. Потом с реализацией реентерантности (#163) они стали содержать ссылки.

Недостаток очевиден — сложность и куча уровней косвенности. Он станет ещё очевиднее, когда мы далее рассмотрим более простое и эффективное решение.

Что с этим делать

Все четыре вида сущностей нужно инициализировать при загрузке модуля (имеются ввиду I- и N-модули). Но конструкторами мы их инициализировать не можем, потому что у нас Си. Можем только константами во время компиляции. Нам нужно, чтобы кто-то дёргал функции инициализации для них.

Отложим на секунду вопрос о том, кто же их дёргает. Подумаем: как?

Допустим, в файле можно использовать объект с конструктором, но только один. Что можно сделать? Можно сформировать массивы указателей соответствующих объектов, и адреса этих массивов передать инициализатору. Примерно так:

static IdentReference ident_BEGIN = { 0, "BEGIN" };
static IdentReference ident_END = { 0, "END" };

static ExternalReference ref_Addm_Nat = { 0, "Add-Nat", 4112458675U, 1433710504U };
static ExternalReference ref_Br = { 0, "Br", 0U, 0U };

static FnResult func_Addm_Digits(…) {…}
static NativeReference nat_ref_Addm_Digits  = {
  "Add-Digits", 4112458675U, 1433710504U, func_Addm_Digits
};

static FnResult func_Subm_Digits(…) {…}
static NativeReference nat_ref_Subm_Digits  = {
  "Sub-Digits", 4112458675U, 1433710504U, func_Subm_Digits
};

static GlobalRef g_file_handles = { 0, sizeof(FILE*) * cMaxFileHandles };

static IdentReference *idents[] = { &ident_BEGIN, &ident_END, NULL };
static ExternalReference *externals[] = { &ref_Addm_Nat, &ref_Br, NULL };
static NativeReference *natives[] = { &nat_ref_Addm_Digits, &nat_ref_Subm_Digits, NULL };
static GlobalRef *globals[] = { &g_file_handles, NULL }

static Initializer init(idents, externals, natives, globals);

Нули символизируют значения счётчика-смещения, NULL в конце массива — признак конца. Провязка ссылками next здесь уже не нужна (поскольку они доступны из массивов). Остальная логика остаётся неизменной.

Теперь, что делать с инициализатором. Он дёргает глобальные сущности, а кто его дёргает? Чтобы его кто-то дёрнул, он должен быть доступен извне. Плюс его имя должно быть уникальным (чтобы не было конфликта имён с другими единицами трансляции). Плюс его имя должно быть известно извне.

У каждого модуля есть свой уникальный идентификатор — пара cookies. Их можно использовать для формирования уникального имени:

…
Initializer init_AAAAA_BBBBB = { idents, externals, natives, globals };

На стадии компоновки можно обеспечить знание идентификаторов каждого из модулей, а это значит, можно сделать их «дёргалку»:

external Initializer init_AAAAA_BBBBB;
external Initializer init_CCCCC_DDDDD;

bool module_initialized = false;
Initializer *all_initializers[] = { &init_AAAAA_BBBBB, &init_CCCCC_DDDDD, NULL };

А рантайм уже знает о том, что в любом модуле с нативным кодом есть глобальный массив all_initializers, и по нему нужно пробежать при запуске.

Собственно, вот минимальное изменение инициализации нативных модулей, которое работает без конструкторов глобальных переменных. Фактически, это перенос того, что делает рантайм Си++, в рантайм Рефала. C++, конечно, использует не предопределённые имена переменных (вроде all_initializers), а имена секций, но суть не меняется.

С глобальными конструкторами множество всех объектов обходилось дважды — рантаймом C++ для вызова конструкторов и рантаймом Рефала для уже своей инициализации. Список глобальных объектов не был виден рантайму Рефала, поэтому на первом проходе объекты провязывались в списки (доступные рантайму Рефала).

В предлагаемой реализации объекты складываются в массивы, которые видны рантайму Рефала, а значит, (а) обе фазы инициализации выполняются за один проход, (б) провязка ссылками становится не нужна.

Флаг module_initialized нужен для обеспечения реентерантности — чтобы избежать повторной инициализации этих объектов из другого домена. Возникает проблема многопоточности, но мы закроем на неё глаза. В дальнейшем мы откажемся от этого флага.

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

Оптимизации нового представления

Во-первых, можно заменить глобальные объекты и массивы указателей на одни только массивы:

enum {
  ident_BEGIN,
  ident_END,
};

static IdentReference idents[] = {
  { 0, "BEGIN" },
  { 0, "END" },
  { 0, NULL },
};

static FnResult func_…(…) {
  … ident_left(ident_ref(BEGIN), bb[1], be[1]) …
}

где ident_ref определена как

#define ident_ref(name) ident_by_id(vm, idents[ident_ ## name].id)

А функция Identifier *ident_by_id(VM *vm, unsigned id) будет находить по id соответствующий идентификатор. Аналогично делаем с ExternalReference.

По факту нативный код в этой ситуации идентифицирует объекты по целочисленному индексу. И эти индексы свои для каждой единицы трансляции. По этим индексам из массивов idents или externals извлекается другой индекс, по которому уже производится обращение ко внутренней таблице модуля.

Почему так сложно? Потому что раньше все объекты объединялись в один список и получали последовательные глобальные номера. Различить объекты из разных единиц трансляции было нельзя. А теперь можно это обеспечить — достаточно initializer_AAAAA_BBBBB инициализировать куками.

Лишний уровень косвенности пропадает — у каждой единицы трансляции теперь будут свои массивы идентификаторов и внешних функций, индексация будет одна.

Но ведь у каждой единицы трансляции уже есть свои массивы с локальной нумерацией! При генерации RASL’а строятся таблицы внешних функций и идентификаторов, а в команды кода помещаются номера из этих таблиц.

А это значит, что параллельных массивов (для RASL’а и нативного кода) строить не надо. Достаточно использовать те же номера. IdentReference и ExternalReference отпадают за ненадобностью.

Как можно упростить GlobalRef? Что делает сейчас GlobalRef<> — его инициализатор определяет смещение в памяти для значения + подсчитывает суммарный объём памяти. Но если память глобальных переменных выделять не для модуля, а для единицы трансляции, то и смещения, и суммарный объём можно определить на стадии компиляции. Достаточно все переменные сложить в одну структуру.

Получаем такую картину:

enum { /* номера те же, что и в RASL */
  ident_True = 1,
  ident_False = 2,
  …
  func_Addm_Nat = 1,
  func_Prout = 2,
  …
};

#line 11, "Library.ref"
enum { MAX_FILE_HANDLE = 40 };
struct Globals {
  …
  FILE *file_handles[MAX_FILE_HANDE];
  …
};

#define VAR_TYPE struct Globals
#line 1234, "Library.c"

static FnResult func_Addm_Nat(…) {…}
static FnResult func_Prout(…) {…}
static FnResult func_Putout(…) {
  struct Globals *globals = vm->globals;
  …
  … globals->file_handles[k] …
  …
}

static const NativeReference natives[] = {
  { func_Addm_Nat, "#Add-Nat" },
  { func_Prout, "*Prout" },
  …
  { 0, 0 },
};

#if defined(VAR_TYPE)
const Initializer init_AAAAA_BBBBB = { AAAAA, BBBB, natives, sizeof(VAR_TYPE) };
#else
const Initializer init_AAAAA_BBBBB = { AAAAA, BBBB, natives, 0 };
#endif

Поскольку в инициализаторе хранятся куки, их не нужно хранить в NativeReference. Если пользователь описал структуру, то он должен определить макрос VAR_TYPE, раскрывающийся в имя этой структуры. Если макрос определён, то размер соответствующего типа заносится в Initializer.

Заметим, что теперь все структуры данных инициализируются на стадии компиляции и для рантайма они могут быть доступны только для чтения. Никакого module_initialized более не нужно.

Вывод

Даже если не переходить на Си, то поразмышлять об этом полезно. В частности, можно прийти к существенному упрощению компиляции нативного кода. Можно даже обойтись без списка инициализаторов на стадии компоновки, а инициализировать Initializer конструктором с побочным эффектом. Всё равно будет польза.

Более того, если полностью реализовать этот подход, упростится реализация модулей типа N (#170), ведь нативный код по сути — ни что иное, как ссылка на массив initializers. Возникают нюансы с передачей функций рантайма — их можно передавать как указатели на функции в виртуальной машине.

Mazdaywik commented 5 years ago

Входные файлы компилятора могут быть не только исходниками, но и скомпилированной парой .rasl+.cpp (в дальнейшем может быть и .rasl+.c). Откуда линковщик узнает имя инициализатора?

Предлагается в первых пяти строках файла размещать комментарий, который обязан содержать строку

INITIALIZER initializer_name

Например:

// INITIALIZER initializer_123456_ABCDEF

/*
INITIALIZER initializer_ABCDEF_09876
*/

И это имя берётся для extern const Initializer …;.

Если такой строки нет — это не ошибка. Это может быть файл рантайма, у которого нет инициализатора.

UPD 22.10.2020: описано в #324.

Mazdaywik commented 4 years ago

Задача переписывания всего на Си делится на две сравнительно независимые части:

Причём первая мягко блокирует вторую. Можно и вполне естественно переписать генерацию кода и используемый из неё API на Си, оставляя реализацию на C++. В этом случае реализация на C++ будет использовать определения структур данных языка C, которые будут вынесены в API (описание типов данных узла, например).

А вот обратный вариант получается неестественным. Реализация зависит от API рантайма, в особенности описания структур данных. Оставлять их на C++, переписывая реализацию на Си, не получится. Не, конечно, можно изловчиться и нагородить костыли, но это глупо.

Переписывание API рантайма и генерации кода тоже можно формально разделить на две части: собственно, переписывание API и переписывание генерации кода. Но в этом смысла нет. Во-первых, зачем нужен C API, если используемый код остаётся плюсовым? Во-вторых, если переписать API, то от C++ в генераторе кода ничего, кроме расширения .cpp, не останется.

На данный момент скомпилированные единицы трансляции представляют собой либо одиночные файлы .rasl, либо пары .rasl + .cpp. Нужно обеспечить поддержку пар .rasl + .c.