Files

8 lines
18 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 368,
"slug": "новый-движок-ctfe",
"title": "CTFE в D: как перенести вычисление в сборку и не сломать её",
"excerpt": "CTFE выполняет функцию там, где компилятору нужно значение на этапе сборки. Разбираем границу между compile-time и runtime, историческую проблему NewCTFE и проверку стоимости такого переноса.",
"contentHtml": "<p>Симптом заметен в двух местах: сборка внезапно занимает минуты и расходует память, либо программа каждый запуск повторяет вычисление, результат которого известен из исходников. В обоих случаях проблема одна — граница между компилятором и готовой программой выбрана без проверки. CTFE (Compile Time Function Execution, выполнение функции во время компиляции) помогает эту границу перенести, но не отменяет стоимость вычисления.</p>\n<p>Практический вопрос звучит так: какие входы известны при сборке, какой результат нужно получить и что будет стоить этот перенос. Если ответ сводится к «компилятор это умеет», решение ещё не доказано. Нужно показать контекст вызова, проверить код на целевой версии компилятора, сравнить сборку с runtime-вариантом и отдельно проверить отрицательный путь.</p>\n<h2>Что именно делает CTFE</h2>\n<p>В D функция может вычислить значение на этапе компиляции, когда это значение требуется соответствующему контексту. Спецификация перечисляет инициализацию статической переменной или manifest constant, размер статического массива, аргумент шаблона, <code>static if</code>, <code>static foreach</code>, <code>static assert</code>, <code>mixin</code>, а также аргументы <code>pragma</code> и <code>__traits</code>. Само имя функции не делает вызов compile-time.</p>\n<p>Та же функция может иметь два режима вызова. В <code>enum</code> или <code>static assert</code> компилятор вычисляет значение заранее. В обычном выражении внутри <code>main</code> вызов остаётся runtime-вызовом. Поэтому по одному исходнику функции нельзя заключить, где она выполнилась: смотреть нужно на место использования.</p>\n<figure><img src=\"/assets/illustrations/ctfe-engine.svg\" alt=\"Схема перехода от AST через интерпретатор CTFE к константе и бинарному файлу\" /><figcaption>Граница CTFE: компилятор вычисляет допустимое значение, а программа получает готовый результат.</figcaption></figure>\n<h2>Минимальный пример с двумя границами</h2>\n<p>Ниже функция не знает о файлах, сети и окружении. Её вход — число, результат — число, поэтому пример можно повторить на чистой машине. <code>enum</code> требует manifest constant, а <code>static assert</code> проверяет результат во время компиляции.</p>\n<pre><code>int square(int value)\n{\n return value * value;\n}\n\nenum squared = square(6);\nstatic assert(squared == 36);\n\nvoid main()\n{\n import std.stdio : writeln;\n\n writeln(squared); // готовое compile-time значение\n writeln(square(6)); // обычный runtime-вызов\n}</code></pre>\n<p>Вызов справа от <code>enum</code> попадает в compile-time-контекст, поэтому <code>squared</code> становится результатом вычисления. Вторая строка в <code>main</code> не обязана использовать CTFE: это обычное выражение и оно может выполниться при запуске. Такой контраст полезнее комментария «функция работает во время компиляции», потому что показывает условие, от которого зависит поведение.</p>\n<p>Пример доказывает только семантическую границу и корректность условия <code>36</code>. Он не доказывает ускорение приложения. Для этого нужен отдельный замер на проектном входе: один и тот же компилятор, одинаковые флаги, чистый кеш и сопоставимый результат.</p>\n<h2>Почему появился NewCTFE</h2>\n<p>Историческое описание NewCTFE в официальном D Blog относится к ноябрю 2016 года. В нём проблема старого интерпретатора объясняется способом представления изменений: интерпретатор проходил узлы AST (абстрактного синтаксического дерева) напрямую и мог копировать цепочку состояний при каждой мутации. На маленьких вычислениях накладные расходы не видны, но длинный цикл резко увеличивает промежуточную память.</p>\n<p>Предложенное направление меняло не саму идею CTFE, а внутренний способ её реализации. Фронтенд проходил AST и передавал операции бэкенду, тот строил промежуточное представление и байткод, который затем интерпретировался. Такое разделение оставляло место для анализа потока данных и уменьшало зависимость от копирования каждого промежуточного узла.</p>\n<p>Это историческое объяснение механизма, а не обещание, что любая современная сборка D использует ровно такую реализацию или даст конкретное ускорение. Источник также описывает состояние проекта и его планы на тот момент. Текущую применимость проверяют по версии DMD, LDC или другого компилятора и по собственной сборке.</p>\n<h2>Ограничения языка важнее названия движка</h2>\n<p>Актуальная спецификация D требует, чтобы функция, выполняемая через CTFE, оставалась исполнимой и в runtime. Compile-time вычисление должно быть эквивалентно запуску функции, а семантика не должна зависеть от значения, доступного только на этапе компиляции. Поэтому функция, которая существует только ради <code>mixin</code> и не может породить runtime-код, не является корректным способом обойти это правило.</p>\n<p>Спецификация запрещает в фактически исполняемом пути обращение к изменяемым статическим переменным, ассемблер, непереносимые приведения, некоторые операции с объединениями и неопределённое поведение. Безопасные указатели допускаются только в оговорённых случаях. Невосстановимые ошибки вроде срабатывания <code>assert</code> недопустимы. Условие в неисполненной ветке не следует оценивать так, будто оно уже нарушило ограничение.</p>\n<p>Спецификация отдельно предупреждает: CTFE может выполняться заметно дольше runtime-варианта, а бесконечный цикл способен повесить компилятор. Результат также может отличаться от runtime при implementation-defined поведении. Поэтому большой цикл, зависимость от платформы и неограниченный вход — причины остановить перенос и сначала поставить измерение.</p>\n<h2>Окружение и псевдопеременная <code>__ctfe</code></h2>\n<p>Compile-time результат должен быть повторяемым при одинаковом исходном коде и входах. Сеть, текущее время, рабочая директория и случайный файл принадлежат окружению запуска; прятать их чтение внутри вычисления опасно даже тогда, когда конкретная версия компилятора допускает нужный вызов. Если вход внешний или большой, явный генератор до сборки обычно даёт лучшее место для кеша, ошибки и артефакта.</p>\n<p>В D есть псевдопеременная <code>__ctfe</code>. Она истинна во время CTFE и ложна в runtime, поэтому через неё можно развести операцию, запрещённую в compile-time, и допустимую замену. В спецификации указано, что эта проверка вычисляется статически и не создаёт runtime-стоимости. Но ветвление удваивает пути сопровождения: для каждой ветки нужен свой тест, а скрывать таким образом внешний эффект не следует.</p>\n<pre><code>int readValue()\n{\n if (__ctfe)\n return 7; // детерминированная ветка для сборки\n\n return loadFromRuntime(); // проектная runtime-операция\n}</code></pre>\n<p>Этот фрагмент — схема границы, а не готовая библиотечная функция: <code>loadFromRuntime</code> должен существовать в проекте и иметь тот же тип результата. Если compile-time ветка не нужна, проще оставить одну функцию без <code>__ctfe</code>. Чем меньше режимов, тем легче доказать одинаковый контракт.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><thead><tr><th>Симптом</th><th>Рабочая гипотеза</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Сборка стала дольше</td><td>Большой вход или цикл ушёл в CTFE</td><td>Собрать минимальный пример и сравнить wall-clock с runtime-вариантом</td><td>Уменьшить вход, разбить расчёт или вернуть его в runtime</td></tr><tr><td>Компилятор расходует много памяти</td><td>Промежуточное состояние растёт быстрее результата</td><td>Зафиксировать пик памяти и воспроизвести случай на отдельной функции</td><td>Изменить алгоритм или вынести подготовку в генератор</td></tr><tr><td>Сборки различаются</td><td>Вход зависит от платформы или окружения</td><td>Повторить сборку в чистом окружении и сравнить входы и артефакты</td><td>Передавать данные явно и оставить внешнее состояние в runtime</td></tr><tr><td>Runtime всё ещё считает значение</td><td>Вызов стоит в обычном выражении</td><td>Проверить контекст, добавить <code>static assert</code> и посмотреть код вызова</td><td>Использовать manifest constant или другой поддерживаемый compile-time-контекст</td></tr><tr><td>Ошибка появляется только на одной версии DMD/LDC</td><td>Зависимость от implementation-defined поведения или поддержки операции</td><td>Записать версию, флаги и минимальный воспроизводимый пример</td><td>Ограничить версию, изменить код или отказаться от переноса</td></tr></tbody></table>\n<h2>Порядок безопасного переноса</h2>\n<ol><li>Назвать результат, который нужен до запуска: число, таблица, строка, размер массива, параметр шаблона или проверяемый инвариант.</li><li>Перечислить все входы функции и отделить литералы и конфигурацию сборки от файлов, сети, времени и переменных окружения.</li><li>Сделать минимальный пример, который собирается на целевой версии DMD, LDC или другого выбранного компилятора.</li><li>Поместить вызов в явный compile-time-контекст и добавить <code>static assert</code> для свойства, которое должно быть неизменным.</li><li>Оставить аналогичный runtime-вызов или тест, чтобы сравнить значение и не принять сам факт компиляции за доказательство.</li><li>Измерить время сборки, пик памяти, размер бинарника и runtime на одинаковом входе до и после изменения.</li><li>Проверить отрицательный путь: большой вход, недоступная операция, другой target или отсутствие compile-time-значения должны приводить к понятному отказу либо оставаться runtime-кодом.</li><li>Зафиксировать версию компилятора и границу применения рядом с кодом. Если стоимость сборки выше пользы запуска, откатить перенос.</li></ol>\n<h2>Критерий готовности</h2>\n<p>Перенос готов, когда входы перечислены, результат детерминирован, compile-time-контекст виден из исходника, <code>static assert</code> ломает сборку при нарушении свойства, а сравнительный замер показывает приемлемую стоимость. Отдельно должны быть проверены runtime-вызов и отрицательный путь. Успешный маленький пример без этих условий — только гипотеза.</p>\n<p>NewCTFE полезно читать как инженерный урок: при росте CTFE-нагрузки узким местом становится не только алгоритм пользователя, но и представление промежуточного состояния в компиляторе. Поэтому переносите маленькие и предсказуемые вычисления, измеряйте границу и не выдавайте историческую архитектуру за гарантию современной версии.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://dlang.org/spec/function.html#ctfe\">D Language Specification: Compile Time Function Execution</a> — контексты CTFE, ограничения, <code>__ctfe</code>, время выполнения и требование runtime-исполняемости функции.</li><li><a href=\"https://dlang.org/spec/version.html#static-assert\">D Language Specification: Static Assert</a> — compile-time-проверка условия и ошибка сборки при ложном результате.</li><li><a href=\"https://dlang.org/blog/2016/11/18/project-highlight-the-new-ctfe-engine/\">The D Blog: Project Highlight: The New CTFE Engine</a> — историческое описание проблем AST-интерпретатора и архитектурного направления с промежуточным представлением и байткодом.</li></ul>"
}