Как я сделал свой язык программирования Tokype
Всем привет! Сегодня я расскажу о своём пет-проекте на Go - Tokype, покажу результаты бенчмарков, а также раскрою его главный секрет.
Tokype - это интерпретатор, который парсит код в AST и выполняет его, обходя узлы дерева. Обычно чистые AST выполняются дольше, чем языки с байт-кодом (например, Python, Lua, PHP) или JIT-компиляцией (LuaJIT, Java, JavaScript, C#).
Мой язык сначала был медленным. Он мог выполнять код в 3-5 раз медленнее Python (прошлые тесты я не могу предоставить. Их результаты потерялись). Я не умел и не умею делать компилятор байт-кода или JIT-компилятор, поэтому я пошёл другим путём.
Справка для тех, кто не знает, что такое байт-код, JIT-компилятор
Компилятор байт-кода:
Сейчас многие языки используют байт-код компиляторы (у Lua - luac, у Erlang - erlc, у Java - javac). Они превращают код в байты. Этот байт-код выполняется в виртуальной машине языка программирования, что делает код быстрее, но не настолько, чтобы выдавать скорость компилируемых языков (вроде C++ или Go).
JIT-компилятор (Just-in-time):
это очень мощная оптимизация для интерпретаторов. JIT (Just-In-Time) компилирует байт-код в машинный код, что делает код быстрее, но он компилирует не всё, только "горячие зоны" (циклы, часто вызываемые функции). Это даёт высокий прирост скорости выполнения. Но чтобы получить максимальную скорость, JIT надо "разогреть", тогда он даст высокую оптимизацию.
Поскольку я не умею делать компиляторы байт-кода и JIT, я подумал: "А можно ли встроить оптимизатор компиляторов в интерпретатор? И какую скорость тогда получит мой Tokype?".
Я решил этому оптимизатору сделать название, и я выбрал TitanJerboa(титан-тушканчик). Jerboa(тушканчик) я выбрал потому что это быстрый зверёк из семейства тушканчиковых, а Titan(титан) для солидности.
Что входит в TitanJerboa?
constantFold (свёртывание констант)
Самая базовая, но очень полезная оптимизация. Если в коде есть выражение, которое можно вычислить прямо сейчас - зачем вычислять его в рантайме?
Было:
Стало:
Как это работает:
То есть всё, что можно посчитать на этапе анализа - считается. Это касается не только арифметики, но и логических выражений, строковых конкатенаций и так далее.
optimizeControlFlow (оптимизация if/elif/else, а также for/while)
Эта оптимизация смотрит на if/elif/else и циклы. Если условие вычисляется в константу, зачем тащить в исполняемый код все ветки? Оставляем только ту, которая реально сработает.
Как это выглядит в коде:
То есть если условие всегда истинно или всегда ложно, мы просто заменяем весь if на одну из его веток. В рантайме интерпретатор даже не увидит, что там был какой-то выбор. С циклами аналогично: если условие заведомо ложно, цикл превращается в пустой блок.
removeDeadCode (удаление мёртвого кода)
Здесь всё просто. Если код никогда не достижим (например, после return или внутри условия, которое всегда false), он удаляется.
optimizeCollections (оптимизация списков)
Представьте, что вы заполняете список в цикле:
Вместо того чтобы выполнять миллион итераций, оптимизатор замечает этот паттерн и превращает его в одно действие:
Как это реализовано:
Сложность падает с O(n) до O(1). Вот это я называю оптимизацией!
foldArithmeticProgression (сворачивание арифметических прогрессий)
Если вы в цикле считаете сумму чисел от 1 до N, зачем гонять процессор через все итерации? Формула Гаусса была придумана не зря.
Было:
Стало:
Интерпретатор даже не увидит цикл - он получит готовое число.
mergeNestedLoop (объединение вложенных циклов)
Вложенные циклы - это боль. Особенно когда они оба итерируются по константам.
Было:
Стало:
Реализация:
Вместо миллиона итераций получаем... ну, технически всё ещё миллион, но с одним циклом вместо двух. На практике это даёт прирост за счёт уменьшения накладных расходов на переходы между циклами.
inlineFunctionCalls (инлайнинг функций)
Вызов функции - это дорого. Особенно если функция маленькая и вызывается часто. Инлайнинг заменяет вызов функции на её тело.
Было:
Стало:
А потом в дело вступает свёртка констант, и мы получаем:
В результате интерпретатор даже не знает, что в коде была функция - он просто выполняет её тело как обычные инструкции. Прирост скорости может быть огромным, особенно в циклах.
removeEmptyFunctions (Удаление пустых функций)
Всё просто: если функция внутри пустая, она удаляется.
Такую функцию просто вырезаем из программы. Зачем хранить то, что не делает ничего полезного?
Это всё даёт максимальную скорость! Но из-за этого мой оптимизатор может перед началом притормозить запуск скрипта, чтобы всё посчитать, оптимизировать и так далее. Все эти оптимизации работают не по отдельности, а каскадом. Сначала инлайнинг, потом свёртка констант, потом удаление мёртвого кода, потом оптимизация циклов.
P.S. Он всё считает и оптимизирует ДО начала запуска кода.
Результаты бенчмарков
И теперь мы подошли к самому главному - бенчмаркам.
Бенчмарк 1:
Терминал:
Бенчмарк 2:
Терминал:
Бенчмарк 3:
Терминал:
Бенчмарк 4:
Терминал:
Да, все бенчмарки показывают 0 секунд. Это не потому, что интерпретатор супербыстрый, а потому что оптимизатор вырезал все циклы ещё до запуска.
Я рассказал почти всё о своём языке. Больше можно узнать в README в моём репозитории GitHub. А также мой язык унаследовал несколько функций из Go (например, многопоточность, +Inf / -Inf, NaN).
Надеюсь, вы оцените мой проект.
К версии 0.0.3 я хочу добавить:
1. Импорт соседних файлов.
2. Импорт библиотек.
3. Встроенные библиотеки.
4. Сделать стабильнее.
5. Добавить значение Null.