DarkRiDDeR12 мин

D CTFE: вычислить константу на этапе компиляции и проверить границу применения

DLangCompile-time

Функция вызывается как обычная, но разработчик не знает, когда выполняется её работа. Цена ошибки — медленная сборка, неожиданные ограничения CTFE или перенос тяжёлой операции в runtime.

Материал о CTFE полезен, если показывает границу: компилятор может выполнить ограниченное вычисление, когда входы известны во время сборки. Это оптимизация и способ сформировать данные, а не обещание, что любой код станет compile-time.

D CTFE: вычислить константу на этапе компиляции и проверить границу применения: схема границ проверки
Иллюстрация показывает границу между симптомом, техническим механизмом и проверяемым действием.

Что сохраняем из исходной заметки

В течение последних 9 месяцев велась работа над проектом под названием NewCTFE, в котором переписываются методы выполненияфункций времени компиляции (СTFE). СTFE считается одной из технологий способных изменить D.

Как следует из названия, CTFE позволяет компилятору выполнять некоторые функции, когда он компилирует исходный код, в котором реализованы функции. Пока все аргументы функции доступны во время компиляции, а функция чиста (не имеет побочных эффектов), тогда функция квалифицируется как CTFE, и компилятор заменяет вызов функции результатом.

Поскольку это неотъемлемая часть языка, чистые функции могут быть вычислены везде, где может находиться константа времени компиляции. Простой пример можно найти в стандартном модуле std.uri, где CTFE используется для вычисления таблицы поиска. Это выглядит так:

private immutable ubyte[128] uri_flags = // indexed by character
({
ubyte[128] uflags;
// Compile time initialize
uflags['#'] |= URI_Hash;
foreach (c; 'A' .. 'Z' + 1)
{
uflags[c] |= URI_Alpha;
uflags[c + 0x20] |= URI_Alpha; // lowercase letters
}
foreach (c; '0' .. '9' + 1) uflags[c] |= URI_Digit;
foreach (c; ";/?:@&=+$,") uflags[c] |= URI_Reserved;
foreach (c; "-_.!~*'()") uflags[c] |= URI_Mark;
return uflags;
})();

Вместо заполнения таблицы магическими значениями используется простой экспрессивный литерал функции. Это намного проще понять и отладить, чем некоторые непрозрачные статические массивы. ({ запускает функцию-литерал, а }) закрывает ее. () в конце говорит компилятору немедленно вызвать этот литерал, чтобы uri_flags стал результатом литерала.

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

Более подробное обсуждение CTFE смотрите в статье H. S. Teoh D Wiki.

Конечно, подобный метод может быть применен и к более сложным проблемам. Например, std.regex можно использовать со специализированным автоматом для выполнения регулярного выражения во время компиляции с использованием CTFE. Однако, как только std.regex используется с CTFE для нетривиальных паттернов, то время компиляции может стать чрезвычайно длительным (в D все, что занимает больше секунды при компиляции — избыточность :)). В конце концов, по мере усложнения паттернов, у компилятора появится нехватка памяти, и, возможно, произойдёт крах всей системы.

Причина этого может крыться в текущей архитектуре интерпретатора CTFE. Это интерпретатор AST (абстрактного синтаксического дерева) — это означает, что он интерпретирует AST во время его обхода. Чтобы представить результат интерпретируемых выражений, он использует классы узлов DMD в AST. Это означает, что на каждое вновь встреченное выражение будет выделено один или несколько узлов AST. В ограниченном цикле интерпретатор может легко создать более 100.000.000 узлов и использовать несколько гигабайт оперативной памяти. Это может быстро израсходовать память.

В issue 12844 имеет место проблема, что std.regex занимает более 16 ГБ ОЗУ для одного шаблона. Также есть issue 6498 , которая показывает, что выполняется простой от 0 до 10.000.000 узлов во время выполнения CTFE и что приводит к критичной нехватки памяти.

Простое освобождение узлов не устраняет проблему, так мы точно не знаем, какие узлы необходимо освободить, и что cделает весь компилятор очень медленным из-за сборщика мусора. К счастью, есть еще один подход, который не выделяет память для каждого вновь встреченного выражения. Он включает в себя компиляцию функции в виртуальную ISA (архитектуру набора инструкций). Эта виртуальная ISA, также известная как байт-код, затем передается выделенному интерпретатору для этой ISA (в случае, когда виртуальный ISA совпадает с ISA хоста, мы называем его JIT (Just in Time) интерпретатором).

Проект NewCTFE занимается реализацией такого интерпретатора байт-кода. Написание фактического интерпретатора (эмулятора CPU для виртуального CPU/ISA) достаточно просто. Однако компиляция кода для виртуального ISA выполняется в точности так же, как и при компиляции его в реальном ISA (хотя виртуальная ISA имеет дополнительное преимущество, которое может быть расширено для индивидуальных потребностей, но это затруднит выполнение JIT позже). Вот почему потребовался всего месяц, чтобы пучить первые простые примеры, работающие на новом движке CTFE, и почему немного более сложные из них все еще не работают даже после 9 месяцев разработки. В конце статьи вы найдете примерный график выполненной к настоящему времени работы (см. оригинал).

Я буду выступать с презентацией на DConf 2017, где я расскажу о своем опыте внедрения движка и объясню некоторые технические детали, особенно относительно компромиссов и архитектурных решений, которые я применил. Текущая оценка состоит в том, что версия 1.0 не будет реализована к тому времени, но я буду заниматься разработкой, пока не закончу данный проект.

Те, кто хочет отслеживать разработку, могут сделать это на форуме D. В будущем я планирую написать еще одну статью о некоторых технических деталях реализации. К тому времени, я надеюсь, что следующий список поможет пролить свет на то, как много работы по реализации NewCTFE.

Данная статья является переводом статьи Штефана Коха, который является разработчиком sqlite-d, встроенного пакета в D для работы с sqlite, также он внес свой вклад в такие проекты, как SDC (Stupid D Compiler) и vibe.d. Он также был ответственен за 10% -ное повышение производительности в текущей реализации CTFE в D и в настоящее время пишет новый движок CTFE.

Оригинал статьи смотрите по ссылке The D Blog/The New CTFE Engine.

Как применять выводы на практике

CTFE полезен там, где результат можно посчитать один раз во время компиляции: таблицы констант, парсинг небольших DSL, генерация однотипного кода, предварительная подготовка строк и проверка инвариантов. Но не стоит превращать компиляцию в полноценный runtime. Чем проще входные данные и чем меньше побочных эффектов, тем легче будет сопровождать проект.

enum table = buildLookupTable();

static assert(table.length == 256);

Механизм без лишних обещаний

CTFE выполняет функцию во время компиляции, если компилятор может интерпретировать её операции и все входы известны. Результат попадает в программу как готовое значение.

Ограничение проявляется на границе окружения: чтение сети, времени, произвольного файла или системного состояния нельзя считать стабильной частью константы. Если результат зависит от среды, его нужно получать явно при запуске.

Полезный тест смотрит на две вещи: программа собирается с заданным входом, а runtime не повторяет вычисление. Для сложной таблицы нужно сравнить выигрыш времени запуска со стоимостью компиляции и размером бинарника.

Минимальный воспроизводимый пример

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

enum string[] routes = buildRoutes();

string[] buildRoutes() {
    // Все входы известны компилятору.
    return ["/", "/catalog", "/checkout"];
}

static assert(routes.length == 3);

void main() {
    // В runtime используем уже готовую таблицу.
    import std.stdio : writeln;
    writeln(routes);
}

Матрица диагностики

Вход: рабочая матрица проверки
ВходПодходит для CTFEПочему
Литерал строкиДаРезультат зависит только от исходника
Массив конфигурацииДа, если он фиксированКомпилятор видит все значения
Файл окруженияНет как общий контрактСодержимое меняется между средами
Сеть и времяНетРезультат не воспроизводим при сборке

Порядок действий

  1. Определить, что именно известно до запуска программы.
  2. Написать маленькую чистую функцию без скрытого окружения.
  3. Добавить `static assert` для главного свойства результата.
  4. Сравнить время сборки и размер бинарника до и после CTFE.
  5. Проверить, что runtime использует готовое значение и не повторяет вычисление.
  6. Оставить runtime-путь для данных, которые зависят от среды.

Ограничения и безопасный следующий шаг

Поддерживаемые операции CTFE расширяются между версиями компилятора.

Compile-time вычисление не делает секрет безопасным: значение может попасть в бинарник.

Большая таблица может ускорить запуск и одновременно замедлить сборку.

После проверки должен остаться конкретный артефакт: вывод команды, тест, diff конфигурации или запись результата. Если его нет, формулировку нужно вернуть к симптому и не выдавать гипотезу за исправление.

Что записать в ревью

Короткая запись должна отвечать на четыре вопроса: какой вход использовали, какой результат увидели, какая граница была проверена и какое действие разрешено дальше. Такая форма полезнее длинного вывода «всё работает»: другой инженер сможет повторить проверку и понять, где заканчивается пример.

Если результат зависит от версии Windows, PHP, Bitrix, D или браузера, версию фиксируем рядом с командой. Если проверка не охватывает сеть, production или реальные пользовательские данные, это ограничение пишем прямо. Тогда следующий шаг расширяет evidence, а не расширяет обещание.

Проверяемые источники

  • D language: CTFE — описывает условия выполнения функции при компиляции
  • D: compile-time programming — показывает исторический пример нового CTFE engine
  • D: static assert — фиксирует проверку свойства на этапе компиляции