Как сделать быстрый интерпретатор динамического языка — перевод статьи Филипа Пизло
Автор FTL JIT в WebKit показывает на примере собственного языка Zef, как разогнать простой AST-интерпретатор в 16,6 раза без JIT, без байткода и без SSA — одними техниками уровня «убери строку, добавь кеш».
Хотите ускорить рантайм своего маленького DSL или учебного языка — но писать JIT нет сил, а байткод-компилятор кажется отдельным проектом? Перевод статьи Филипа Пизло (автора JavaScriptCore-FTL, B3 и Riptide GC в WebKit) показывает, как простой AST-интерпретатор для собственного языка Zef он разогнал в 16,6 раза без единой строчки машинного кода, а с переходом на обычный C++ — в 67 раз.
Пизло — человек, от которого обычно ждут постов про «как мы выкрутили очередной трюк в JIT JavaScriptCore». Его прошлые тексты — это FTL JIT, B3, Riptide GC и другие вещи, которые требуют двух лет опыта в компиляторах. Но этот текст — принципиально другой. Он показывает ровно то, о чём обычно не пишут: что делать, когда у вас ещё нет JIT, а GC ещё не является главной болью.
Стартовая версия интерпретатора Zef была в 35 раз медленнее CPython 3.10, в 80 раз медленнее Lua 5.4.7 и в 23 раза медленнее QuickJS-ng 0.14.0. После 21 оптимизации (ни одной из которых не требует SSA-преобразований (static single assignment), переделки сборщика мусора, байткода или машинного кода) тот же интерпретатор проигрывает CPython всего в 2,1 раза, Lua — в 4,8 раза, QuickJS — в 1,35 раза. Если же собрать тем же обычным C++ без Fil-C — Zef становится быстрее CPython в 1,9 раза и быстрее QuickJS в 3 раза.
Ключевые выводы
О чём статья Пизло и что из неё выносить
Простые техники, которые выжимают 16x на пустом месте
Представление значений: tagged 64-битные значения с NaN-boxing (упаковка целых и указателей в неиспользуемые биты double) позволяют держать числа на стеке и отличать их от указателей по битам.
Symbols вместо строк: один раз интернировать все имена методов и переменных в Symbol*, потом сравнивать указатели — даёт +18%.
Object Model + Inline Caches + Watchpoints: самый большой прирост (+355%, 4,55x) — одна мегакоммит-оптимизация, без которой остальное не работает.
Специализация мелочей: выделенный AST-узел для a + b вместо строкового a.add(b), Getter/Setter-инференс, специализированные ZeroArguments/OneArgument/TwoArguments.
Штраф Fil-C — 4x: memory-safe C, в котором автор пишет, даёт GC бесплатно, но накладывает штраф; итоговый буст после перехода на обычный C++ — 67x.
О каком интерпретаторе речь
Zef — это учебный динамический язык, который Пизло написал для удовольствия. В нём есть классы с наследованием, приватные поля (по умолчанию), замыкания, кастомные операторы, getter'ы и setter'ы. На уровне идиом он похож на Ruby и тихую версию JavaScript. Исходный интерпретатор — наивный AST-walker, в котором каждая нода — это C++-объект с виртуальным методом Node::evaluate, а все переменные живут в std::unordered_map<std::string, Value>.
Весь исходный код интерпретатора, diff-просмотрщики для каждой оптимизации и бенчмарки выложены на zef-lang.dev/implementation. Бенчмарки — набор ScriptBench1: Richards (OS scheduler), DeltaBlue (constraint solver), N-Body (физика) и Splay (самобалансирующееся двоичное дерево). Все портированы на Zef, Lua, Python и JavaScript.
Одна деталь про измерения: Пизло собирает свой интерпретатор компилятором Fil-C++ — это его собственный memory-safe C, который даёт сборщик мусора из коробки, но платит за это примерно 4x производительности. То есть когда в тексте написано «в 35 раз медленнее CPython», держите в голове, что 4x из этого — стоимость безопасности памяти, а остальные ~9x — это то, что будем выжимать.
Исходная точка: в 35 раз медленнее Python
В базовом варианте Пизло сделал два единственных осознанных про производительность выбора:
- 64-битное tagged-представление значений. Значение может быть int32, double или
Object*. Double'ы сдвигаются на0x1000000000000— техника из JavaScriptCore, которую в литературе зовут NaN-boxing. Целые и указатели лежат как есть. Это позволяет определить тип значения битовой проверкой и не выделять int'ы в куче. - Язык — C++. Пизло обосновывает: «Java — потолок низкоуровневых оптимизаций слишком низкий, Rust — сборщик мусора требует глобального изменяемого состояния и циклических ссылок, с этим придётся драться или писать много
unsafe».
Всё остальное — сознательные «плохие» решения ради скорости разработки:
- Fil-C++ — штраф 4x, но GC бесплатно.
- Рекурсивный AST-интерпретатор через виртуальные вызовы.
std::stringвезде — для имён переменных, методов, полей.std::unordered_mapвезде — на каждыйGetпеременной идёт хеш-лукап по строке.- Цепочки рекурсивных вызовов по scope chain: класс внутри функции внутри класса внутри функции — каждый доступ проходит через несколько уровней вызовов.
Результат — 35x медленнее CPython, 80x медленнее Lua, 23x медленнее QuickJS. Дальше — что с этим можно сделать, не переписывая интерпретатор на JIT.
#1–2: Прямой вызов операторов и RMW
В Zef выражение a + b эквивалентно a.add(b). Наивный парсер делает одну и ту же AST-ноду для обоих случаев — DotCall(a, "add", [b]). Каждый плюс превращается в лукап строки "add" в Value::callMethod, который каскадом сравнивает её со всеми возможными именами операторов.
Первая оптимизация — парсер генерирует отдельные ноды Binary<> и Unary<>, каждая со своим виртуальным evaluate, который сразу зовёт Value::add, Value::sub и так далее. Без строковых сравнений на каждый плюс.
+17,5%. Вторая оптимизация делает то же самое для += и аналогов — отдельные ноды SetRMW, DotSetRMW, SubscriptRMW. +3,7%. Итог: 1,22x быстрее старта.
#3: Убрать проверку IntObject из быстрого пути
В базовой версии Value различал четыре случая: tagged int32, tagged double, IntObject (для int64, не влезающих в 32 бита) и всё остальное. Фастпас вызывал isInt(), который через виртуальный вызов спрашивал Object::isInt() — на случай, если под обложкой лежит IntObject.
Правка — пусть Value работает только с int32 и double, а весь int64-код уезжает внутрь IntObject. +1%. Мелочь, но копеечка.
#4: Symbols вместо std::string
Это первый по-настоящему большой прирост. В исходном интерпретаторе std::string — это ключ везде, где есть хеш-лукап: локальные переменные, dot-доступ, имена операторов, имена методов. То есть на каждый x + 1 идут хеширование строки "x", strcmp на каждой коллизии, ещё раз для "+".
Оптимизация — классический interning. Новый класс Symbol. Строки превращаются в Symbol* через глобальный hash-consing — одна строка = один указатель. Равенство Symbol* теперь проверяется сравнением указателей, а хеш-таблицы ключуются по Symbol*, а не по std::string.
Правка большая по количеству затронутых файлов, но по сути тривиальная — меняются сигнатуры функций с const std::string& на Symbol*. +18%. Суммарно: 1,46x быстрее старта.
#5: Value Inline
Простая техника: вынести часть методов Value в отдельный заголовочник valueinlines.h, чтобы они видели типы, которые нельзя было включить в value.h из-за циркулярных зависимостей. Компилятор теперь может их инлайнить в горячих местах. +2,8%.
#6: Объектная модель + inline caches + watchpoints — 4,55x одним махом
Самая большая оптимизация в статье, и единственная, которую Пизло специально защищает:
Иногда единственный способ сделать реализацию языка быстрее — это посадить большой патч. Не позволяйте никому говорить, что хороший инженерный труд бывает только в маленьких удобоваримых изменениях. Это не всегда так. И точно не так, если вы хотите иметь быструю реализацию динамического языка.
Патч объединяет три независимо бесполезных вещи:
Новая объектная модель: Storage + Offsets
В исходной версии каждый lexical scope выделял объект Context, а внутри Context жил hashmap с «полями» — локальными переменными. Объекты пользовательских классов были хуже: каждый объект содержал hashmap «класс → Context», потому что если Bar наследует Foo, поля у них приватные и могут иметь одинаковые имена — нужно различать, к какому классу относится поле.
Новая идея — Storage, который держит данные по Offsets, вычисленным на этапе resolve AST. Context всё ещё существует, но создаётся заранее, а объекты на рантайме просто выделяют Storage размера, который Context уже посчитал. Доступ к полю становится индексом по offset'у, а не лукапом в хеш-таблице.
Inline caches
Классическая техника — обычно её описывают в контексте JIT-компиляторов. Но здесь Пизло применяет её в интерпретаторе: для каждой AST-ноды типа expr.name запоминается тип, который expr имел в прошлый раз, и offset, куда резолвилось name. Запоминание — через placement-new на месте старой ноды: под той же памятью лежит новая специализированная нода, которая умеет быстрый путь.
Специализация может быть: «прямой load из Storage» (для локальной переменной), «проверка класса + direct call в функцию, которую видели в прошлый раз» (для метода). Плюс chain steps и watchpoints, если access требовал обхода scope chain.
Watchpoints
Представим: класс Foo внутри lexical scope, в котором есть переменная x. Метод Foo обращается к x. Внутри Foo имени x нет. Казалось бы, можно делать доступ без проверок. Но кто-то может унаследовать Foo и добавить геттер с именем x — тогда доступ должен пойти в геттер.
Watchpoint — это пассивный охранник: «если кто-то переопределил имя x в подклассе, инвалидируй мой inline cache». Пока имя не переопределяли, доступ работает на минимальных циклах.
Почему всё вместе
Пизло объясняет, почему эти три вещи не разделить:
- Новая объектная модель без inline caches не даёт никакого прироста.
- Inline caches без watchpoints не могут кешировать большинство интересных случаев — слишком много условий.
- И объектная модель, и watchpoints должны работать вместе с самого начала.
+355% — 4,55x быстрее. После этого Zef в 5,2 раза медленнее CPython, в 11,7 раза медленнее Lua, в 3,3 раза медленнее QuickJS. То есть накладные расходы Fil-C++ (примерно 4x) — это уже почти вся разница. Суммарно: 6,8x быстрее старта.
#7: Arguments — свой тип вместо std::vector
Раньше аргументы функций передавались как const std::optional<std::vector<Value>>&. optional нужен, чтобы отличать геттер-вызов o.getter от функции без аргументов o.function() — в Zef это разные вещи для некоторых типов. То есть каждый вызов аллоцировал std::vector, копировал его, и ещё раз копировал в scope функции.
Правка — новый тип Arguments, физически совпадающий с layout scope, который callee создаст. Caller выделяет его напрямую, callee не копирует. Плюс в Fil-C++ std::optional всегда живёт в куче из-за специфики invisicaps — это отдельный штраф. +33%.
#8–10: Специализация геттеров, сеттеров и callMethod
Много методов в Zef устроены тривиально:
Это запись readable f. Метод fn f f — просто чтение переменной. Гонять это через полноценный eval AST'а расточительно. Оптимизация #8 — инференс: если тело функции — это просто Get, заменяем UserFunction на специализированный GetterFunction, делающий load по известному offset'у. +5,6%.
Оптимизация #9 — то же самое для сеттеров (fn set_x(v) x = v). Инференс сложнее — нужно матчить параметр сеттера как источник присваивания. +3,4%. Оптимизация #10 — тривиальная однострочная: inline-инг callMethod в заголовочник. +3,2%. Суммарно: 10,2x быстрее старта.
#11: Глобальная hashtable для method dispatch
Когда inline cache промахивается, старый путь лез через ClassObject::tryCallMethod и tryCallMethodDirect, которые делают два хеш-лукапа на каждый уровень иерархии наследования: «есть ли метод в этом классе» и «есть ли вложенный класс с этим именем». O(depth × 2).
Правка — глобальный hashmap (ClassObject*, Symbol*) → callee, который запоминает все успешные резолвы. Промах по IC теперь — один лукап в эту таблицу, и только если её нет — полный медленный путь. +15%. Суммарно: 11,8x.
#12: Избавиться от std::optional на горячем пути
В Fil-C++ std::optional почти всегда аллоцируется в куче — из-за особенностей union-типов и invisicaps. LLVM может непредсказуемо потерять capabilities указателей внутри union'ов; Fil-C++ компилятор страхуется и вставляет интринсики, которые заставляют всё, что выглядит как union, аллоцировать на heap.
Правка — обход кода, ведущего к std::optional на горячем пути. +1,7%. Важный сайд-эффект для тех, кто пишет на Fil-C++: избегайте union-типов в горячем коде.
#13: ZeroArguments, OneArgument, TwoArguments
Большинство встроенных функций Zef берут 0, 1 или 2 аргумента. Им не нужен полноценный Arguments-объект — он просто занимает память. Три новых типа: ZeroArguments, OneArgument, TwoArguments. Функции шаблонизируются и инстанцируются отдельно для каждого.
ZeroArguments нужен отдельно, потому что (Arguments*)nullptr уже используется для геттер-вызова. Теперь ZeroArguments означает «функция без аргументов». +3,8%.
#14: Slow paths Value через static + by value
Раньше медленные пути Value были member-функциями и принимали неявный const Value*. Значит, caller должен был stack-аллоцировать Value. В Fil-C++ любая stack-аллокация это heap-аллокация.
Правка — сделать эти методы static и принимать Value по значению. Аллокации не нужны. +10%. Суммарно: 13,6x. Это ещё одна «специфичная для Fil-C++» оптимизация, которая в Yolo-C++ (обычный C++) была бы бесплатной по умолчанию.
#15–17: Дедупликация, быстрый sqrt и toString
#15 — «оптимизация» в кавычках: убрать дублированный код в DotSetRMW. Пизло надеялся на прирост от уменьшения кода. Прироста нет — но код чище.
#16 — специализация Dot-ноды для value.sqrt. Inline caches хороши для объектов, но для числовых примитивов они не работают — там фастпас другой, через Binary<>/Unary<>. sqrt в базовом варианте летел через общий путь. Теперь — свой специализированный код. +1,6%.
#17 — то же самое для toString. Плюс чуть меньше аллокаций при конвертации int → string. +2,7%. Суммарно: 14,2x.
#18: Специализация литералов массивов
Код вроде my whatever = [1, 2, 3] по-старому каждый раз спускался в AST и вычислял 1, 2, 3. Но если литерал константный — результат известен на парсинге.
Оптимизация — специализированная ArrayLiteral-нода, которая при вычислении просто копирует заготовленный массив значений. +8,1%. Суммарно: 15,35x.
#19: Value::callOperator по значению
Та же идея, что в #14 — не передавать Value по ссылке в медленный путь callOperator, а принимать его по значению. Ещё одна Fil-C++-специфичная аллокация убирается. +6,5%. Суммарно: 16,3x.
#20–21: Настройки компилятора и отключение ассертов
#20 — билд-система: отключить RTTI и libc++ hardening, потому что Fil-C++ их всё равно перекрывает. Ни одного изменения в C++-коде, только флаги сборки. +1,8%.
#21 — отключить по умолчанию ассерты. Код использовал макрос ZASSERT (всегда проверяет). Замена на ASSERT (только если ASSERTS_ENABLED) должна была дать прирост, но — не дала. Зато готовит код к сборке в обычном C++.
После 21 оптимизации: 16,6x быстрее старта. Zef в 2,1x медленнее CPython, в 4,8x медленнее Lua, в 1,35x медленнее QuickJS. Стартовали с 35x, 80x и 23x разрывов — оказались в окрестностях конкурентов, без строчки JIT.
Переход на обычный C++ — ещё 4x
Финальный эксперимент — собрать тот же код обычным C++ (GCC 11.4.0) вместо Fil-C++. Пизло называет это Yolo-C++ — без гарантий безопасности памяти. Получается ещё 4x. Суммарно: 67x быстрее базовой версии. Zef в 1,9x быстрее CPython 3.10, в 1,2x медленнее Lua и в 3x быстрее QuickJS.
Сборка неидеальна: вместо Fil-C++ GC там calloc, память не освобождается — интерпретатор течёт, просто ScriptBench1 слишком короткий, чтобы это заметить. С реальным GC Yolo-C++-версия была бы ещё быстрее.
В этот момент Zef в 1,9 раза быстрее CPython 3.10, в 1,2 раза медленнее Lua 5.4.7 и в 3 раза быстрее QuickJS-ng 0.14.0. Мы стали в 67 раз быстрее, чем там, где начали.
Итоговая таблица всех оптимизаций
Что получается по ходу работы — в одной таблице (геосреднее по четырём бенчмаркам, чем быстрее, тем лучше):
Частые вопросы
Зачем мне это читать, если я не пишу компиляторы?
Если вы когда-нибудь делали простой интерпретатор своего языка — DSL для конфига, шаблонизатор, pet project — 90% этих оптимизаций применимы и там. Symbols вместо строк, inline caches, специализация тривиальных методов — всё это техники, которые не требуют писать JIT.
Почему самая большая оптимизация — это патч-монстр из трёх вещей?
Object Model, Inline Caches и Watchpoints нельзя разделить: IC работают только поверх новой объектной модели, а IC бесполезны без watchpoints, потому что слишком много условий, которые иначе не кешируются. Пизло в статье специально оправдывается, что хороший инженерный результат не всегда получается маленькими атомарными PR'ами.
Что такое Fil-C++ и при чём он здесь?
Fil-C++ — это memory-safe версия C, автор которой — Филип Пизло. Она даёт сборщик мусора из коробки, но платит за это примерно 4x производительности. Zef собран на Fil-C++, поэтому часть оптимизаций специфичны для него (например, избежание std::optional — в Fil-C++ union-типы аллоцируются на куче). В обычном C++ некоторые из этих оптимизаций не дают ничего.
Что такое NaN-boxing (tagged values)?
Это способ держать разные типы в одном 64-битном слове. Вы пользуетесь тем, что у IEEE 754 double есть большое пространство значений NaN, которые никогда не производятся в обычной арифметике. В эти NaN-биты можно упаковать int32 или указатель. В Zef double'ы сдвинуты на константу, int32 и указатели лежат нативно — тип определяется битовым тестом без разыменования.
Что такое inline caches и почему они помогают в интерпретаторе?
Inline cache — место в коде, которое помнит последний тип receiver'а и последний результат lookup'а. Для expr.name кешируется класс expr-а и offset поля name. Если тип не изменился — доступ без хеш-таблицы. В классическом применении IC живут в машинном коде JIT, но Пизло показывает, что их можно уложить прямо в AST-ноды через placement-new.
Выводы
Главное в этом тексте Пизло — не сами оптимизации, а то, что они все простые. SSA здесь нет. GC переделывать не надо. Байткода нет. Машинного кода нет. Есть базовые вещи: не хешировать строки (Symbols), не аллоцировать лишнее (Value by value, специализированные Arguments), не ходить по hashmap'ам (Offsets + IC), запомнить то, что уже видели (inline caches), инвалидировать кеш только когда реально что-то поменялось (watchpoints).
Для тех, кто пишет интерпретатор с нуля — список в статье Пизло работает как чеклист. Первые оптимизации (#1–#5) дают суммарные 1,5x — они дешёвые. #6 даёт 4,55x — это день работы, но переворачивает игру. Всё остальное — серия из 3–8% приростов, которые в сумме добирают разницу.
Для тех, кто хочет потом написать JIT — эта статья объясняет, как выглядит база, на которую JIT потом ложится без сюрпризов. NaN-boxing, новая объектная модель, IC и watchpoints — это ровно то, что должно быть у интерпретатора до того, как вы начнёте генерировать машинный код.
Оригинал: How To Make a Fast Dynamic Language Interpreter, Filip Pizlo, 21 апреля 2026 года. Автор — один из ключевых инженеров WebKit JavaScriptCore, автор FTL JIT, B3, Riptide GC.