Как сделать быстрый интерпретатор динамического языка — перевод статьи Филипа Пизло

Автор 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 одним махом

Самая большая оптимизация в статье, и единственная, которую Пизло специально защищает:

Иногда единственный способ сделать реализацию языка быстрее — это посадить большой патч. Не позволяйте никому говорить, что хороший инженерный труд бывает только в маленьких удобоваримых изменениях. Это не всегда так. И точно не так, если вы хотите иметь быструю реализацию динамического языка.
Филип Пизлоавтор FTL JIT в WebKit

Патч объединяет три независимо бесполезных вещи:

Новая объектная модель: 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 устроены тривиально:

			class Foo {
    my f
    fn (inF) f = inF
    fn f f  # геттер: вернуть локальную f
}
		

Это запись 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 раз быстрее, чем там, где начали.
Филип Пизлоавтор WebKit JavaScriptCore

Итоговая таблица всех оптимизаций

Что получается по ходу работы — в одной таблице (геосреднее по четырём бенчмаркам, чем быстрее, тем лучше):

			Изменение                    | vs старт   | vs CPython   | vs Lua       | vs QuickJS
-----------------------------|-----------|--------------|--------------|------------
Базовая версия               | 1x        | 35,4x медл.  | 79,6x медл.  | 22,6x медл.
#1  Direct Operators          | 1,18x     | 30,2x медл.  | 67,7x медл.  | 19,2x медл.
#2  Direct RMWs               | 1,22x     | 29,1x медл.  | 65,3x медл.  | 18,5x медл.
#3  Avoid IntObject           | 1,23x     | 28,8x медл.  | 64,7x медл.  | 18,3x медл.
#4  Symbols                   | 1,46x     | 24,3x медл.  | 54,6x медл.  | 15,5x медл.
#5  Value Inline              | 1,50x     | 23,7x медл.  | 53,2x медл.  | 15,1x медл.
#6  Object Model + IC + WP    | 6,82x     | 5,2x медл.   | 11,7x медл.  | 3,3x медл.
#7  Arguments                 | 9,05x     | 3,9x медл.   | 8,8x медл.   | 2,5x медл.
#8  Getter Specialization     | 9,55x     | 3,7x медл.   | 8,3x медл.   | 2,4x медл.
#9  Setter Specialization     | 9,87x     | 3,6x медл.   | 8,1x медл.   | 2,3x медл.
#10 callMethod inline         | 10,19x    | 3,5x медл.   | 7,8x медл.   | 2,2x медл.
#11 Global Hashtable          | 11,76x    | 3,0x медл.   | 6,8x медл.   | 1,9x медл.
#12 Avoid std::optional       | 11,96x    | 2,96x медл.  | 6,65x медл.  | 1,89x медл.
#13 Specialized Arguments     | 12,39x    | 2,86x медл.  | 6,4x медл.   | 1,82x медл.
#14 Value Slow Paths          | 13,64x    | 2,60x медл.  | 5,8x медл.   | 1,65x медл.
#15 Dedup DotSetRMW           | 13,61x    | 2,61x медл.  | 5,85x медл.  | 1,66x медл.
#16 Fast sqrt                 | 13,82x    | 2,56x медл.  | 5,76x медл.  | 1,63x медл.
#17 Fast toString             | 14,20x    | 2,50x медл.  | 5,61x медл.  | 1,59x медл.
#18 Array Literal             | 15,35x    | 2,31x медл.  | 5,18x медл.  | 1,47x медл.
#19 callOperator by value     | 16,34x    | 2,17x медл.  | 4,87x медл.  | 1,38x медл.
#20 Compiler Options          | 16,64x    | 2,13x медл.  | 4,78x медл.  | 1,36x медл.
#21 No Asserts                | 16,65x    | 2,13x медл.  | 4,78x медл.  | 1,36x медл.
Yolo-C++                      | 66,96x    | 1,89x быстр. | 1,19x медл.  | 2,97x быстр.
		
Частые вопросы
1
Зачем мне это читать, если я не пишу компиляторы?

Если вы когда-нибудь делали простой интерпретатор своего языка — DSL для конфига, шаблонизатор, pet project — 90% этих оптимизаций применимы и там. Symbols вместо строк, inline caches, специализация тривиальных методов — всё это техники, которые не требуют писать JIT.

2
Почему самая большая оптимизация — это патч-монстр из трёх вещей?

Object Model, Inline Caches и Watchpoints нельзя разделить: IC работают только поверх новой объектной модели, а IC бесполезны без watchpoints, потому что слишком много условий, которые иначе не кешируются. Пизло в статье специально оправдывается, что хороший инженерный результат не всегда получается маленькими атомарными PR'ами.

3
Что такое Fil-C++ и при чём он здесь?

Fil-C++ — это memory-safe версия C, автор которой — Филип Пизло. Она даёт сборщик мусора из коробки, но платит за это примерно 4x производительности. Zef собран на Fil-C++, поэтому часть оптимизаций специфичны для него (например, избежание std::optional — в Fil-C++ union-типы аллоцируются на куче). В обычном C++ некоторые из этих оптимизаций не дают ничего.

4
Что такое NaN-boxing (tagged values)?

Это способ держать разные типы в одном 64-битном слове. Вы пользуетесь тем, что у IEEE 754 double есть большое пространство значений NaN, которые никогда не производятся в обычной арифметике. В эти NaN-биты можно упаковать int32 или указатель. В Zef double'ы сдвинуты на константу, int32 и указатели лежат нативно — тип определяется битовым тестом без разыменования.

5
Что такое 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.