[Перевод] Погружение в компиляторы Kotlin

6c38d332550568fd7efb5f3edef32e81.jpeg

Привет, меня зовут Мялкин Максим, я занимаюсь мобильной разработкой в KTS.

Не за горами выпуск новой версии Kotlin 2.0, основной частью которого является изменение компилятора на K2. 

По замерам JB, K2 ускоряет компиляцию на 94%. Также он позволит ускорить разработку новых языковых фич и унифицировать все платформы, предоставляя улучшенную архитектуру для мультиплатформенных проектов.

Но мало кто погружался в то, как работает K2, и чем он отличается от K1. 

Эта статья более освещает нюансы работы компилятора, которые будут полезны разработчикам для понимания, что же JB улучшают под капотом, и как это работает.

Контент статьи основывается на выступлении и овервью, но с добавлением дополнительной информацией.

Оглавление

Как работает компилятор

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

Чтобы превратить исходный код в машинный, компилятор проводит несколько промежуточных преобразований. Если очень упростить, то компилятор делает две вещи:

27087cd740cefa8036228a3fabb6ca55.png

Иногда стандартных функций Kotlin недостаточно. В таких случаях разработчики выходят за рамки стандартных инструментов и используют плагины компилятора.

Плагины встраиваются в работу компилятора Kotlin на различных фазах компиляции и меняют стандартное преобразование кода компилятором. 

202f747c54865c34df0132635279f51c.png

Плагины компилятора используют такие библиотек как Jetpack Compose, Kotlinx serialization.

Kotlin компилятор состоит из 2 частей:

  • Frontend

  • Backend

    • отвечает за генерацию кода для целевой (target) платформы: JVM, JS, Native, WASM (экспериментальный)

56e8b28531b3b0326d360f788f139ccb.png

Остановимся подробнее на каждой из частей.

Frontend

У компилятора Kotlin есть две фронтенд-реализации:  

  • K1 (FE10-) 

  • K2 (Fir-)

Упрощенно весь процесс работы фронтенда выглядит так:

  • Компилятор принимает исходный код 

  • Производит синтаксический и семантический анализ кода

  • Отправляет данные на бэкенд для последующей генерации IR (Intermediate representation) и целевого кода платформы (target)

1f56c4f1e203bf04bb04fb29d8663a18.png

Обе реализации похожи, но K2 вводит дополнительное форматирование данных перед тем, как отправить преобразованный код на бэкенд. 

Реализация K1

2afbfd412cd2a88b69f5b706e1c82cd8.png

Реализация K1 работает c:

  • PSI(Programming Structure Interface)

    • PSI — это слой платформы IntelliJ, который занимается парсингом файлов и созданием синтаксических и семантических моделей кода (что такое синтаксис и семантика рассмотрим позже)

  • Дескрипторами

    • В зависимости от типа элемента дескрипторы могут содержать разную информацию о нем. Например дескриптор класса: контент класса, overrides, companion object и тд.

  • BindingContext— это Map, которая содержит информацию о программе и дополняет PSI.

Во время обработки K1 отправляет PSI и BindingContext на Backend, который на входе использует преобразование PSI в IR (Psi2Ir) для дальнейшей работы. 

Как работает K1

Общая схема работы выглядит следующим образом

5727f97056517eff98516ca70f34d2bb.png

Есть 2 фазы:  

  • Parsing Phase — разбирает что именно написал разработчик. На этой фазе еще неизвестно, будет ли компилироваться написанный код. Здесь проверяется, что код написан синтаксически верно.

  • Resolution Phase — производит анализ, необходимый для понимания, будет ли код компилироваться и является ли он семантически правильным (более детальный разбор Resolution Phase).

Разберём дальше разницу между синтаксисом и семантикой.

Чтобы разобраться в работе компилятора K1, предположим, что у нас есть исходный код 

fun two() = 2 

Пока что для компилятора это только строка текста.

Parsing Phase

Задача синтаксического парсинга

Задача парсинга — проверить структуру программы и убедиться, что она верная и следует грамматическим правилам языка. 

Пример:

adb0532c76d3dd7f4af419179adb371a.png

Этапы Parsing phase

33e6abdaadc3bcc914c9994631c794b6.png

  1. Все начинается с лексического анализа (Lexer Analysis). Сначала Kotlin Parser сканирует исходный код (строку) и разбивает его на лексические части (KtTokens). Например:

    • ( —> KtSingleValueToken.LPAR

    • ) —>KtSingleValueToken.RPAR

    • = —>KtSingleValueToken.EQ

    В нашем примере разбиение на токены выглядит так:

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

  2. Далее начинается синтаксический анализ (Syntax Analysis). Его первый этап — это построение абстрактного синтаксического дерева (AST), которое состоит из лексических токенов. Важная особенность: дерево уже представляет из себя не последовательность символов, а имеет структуру

    Обратите внимание, что структура этого дерева совпадает со структурой нашего исходного кода. В нашем примере это выглядит так:

    fb76db748ef1fbec2e7e8288de575e6d.png
  1. Следующим этапом мы накладываем PSI на узлы абстрактного синтаксического дерева. Теперь у нас есть PSI-дерево, которое содержит дополнительную информацию по каждому элементу:

    de932009d0f8cc2fd0fd1e386ae6e491.png

На этом этапе мы ищем только синтаксические ошибки. Другими словами, мы видим правильно написанный код, но пока не можем предсказать, скомпилируется он или нет и самое главное не понимаем смысл этого кода. 

В IntelliJ IDEA, вы можете загрузить плагин PSIViewer чтобы смотреть PSI для кода, написанного вами. 

165821e8b1855a9d51d6dbf38e93d51e.png

Resolution Phase

Задача семантического парсинга

Чтобы понять смысл написанного кода используется Resolution Phase

Здесь PSI-дерево проходит набор этапов, в процессе которых генерируется семантическая информация, позволяющая понять (resolving) имена функций, типов, переменных и откуда они взялись. 

Все мы в коде видели ошибки из этой стадии. 

11ba47d11cf39386fc081444fd0a1c73.png

 Семантическая информация содержит данные о:

Переменных и параметрах

990679b5695fc4eb454e6f5f69b88d14.jpg

На стадии семантического анализа нужно понять, что, используя pet внутри функции play, мы используем параметр функции, объявленный в первой строке

Типах

b060120a757af3fd4cb173261860d6d3.png

Семантическая информация содержит данные о том, на какие типы ссылается код, в каком месте они объявлены.

Вызовах функций

4c07be40d527d5ac1c9c54e9d211135e.png

Может существовать много функций meow, и требуется определить, какую именно функцию нужно вызвать. Эта стадия занимает большую часть семантического анализа. Тк meow может быть: обычной функцией внутри класса, extension, property. Функция может находиться внутри текущего модуля, другого модуля или в библиотеке. На этапе семантического анализа нужно найти все функции с нужным названием и выбрать наиболее подходящую функцию для конкретной ситуации.

Эта стадия отвечает на похожие вопросы:

  • Откуда появилась эта функция/переменная/тип для использования

  • Точно ли две строки относятся к одной и той же переменной или это разные переменные в зависимости от контекста где они используются?

  • Какой тип у конкретного объекта?

Производится выведение типов

val list1 = listOf(1, 2, 3)
val list2 = listOf("one", "two", "three")

Представим, что у нас есть такая запись.

Тут используется generic-функция listOf

public fun  listOf(vararg elements: T): List

Задача семантического анализа — вывести аргументы типа для каждого использования

val list1 = listOf(1, 2, 3)

В первом случае T = Int

val list2 = listOf("one", "two", "three")

Во втором — T = String.

Этапы Resolution Phase

Реализация K1 выполняет вычисление на PSI-дереве, создает дескрипторы и BindingContext map, а затем передает все это на бэкенд. В итоге бэкенд преобразует полученную информацию в IR с помощью Psi2Ir.

Вся эта информация будет использоваться дальше на бэкенде:

ece61025f9a00c4e4606f0e8d9bb1c46.pngПример с деревом PSI и семантической информацией BindingContext

В этом примере можно увидеть как исходный код (строка) преобразуется в структуру (дерево) и по каждому узлу дерева добавляется дополнительная информация (семантика).

8361fd265640179683030531eb4af37f.png

Проблемы K1

Мы разобрались с тем, как работает фронтенд-реализация K1. Обозначим этот процесс на схеме K1:

745b5065de514b84b60da07976b05ad0.png

У этой реализации есть свои недостатки (объяснение Дмитрия Новожилова в kotlin slack). Преобразование через PSI и BindingContext приводит к проблемам с производительностью компилятора. 

Тк компилятор работает с ленивыми дескрипторами, то выполнение постоянно прыгает между разными частями кода, это сводит на нет некоторые JIT-оптимизации. Кроме этого много информации resolution phase хранится в большом BindingContext, из-за этого ЦП не может хорошо кэшировать объекты. 

Все это ведет к проблемам с производительностью.

Реализация K2

Чтобы повысить производительность компилятора и улучшить архитектуру решения, JB создала новую фронтенд-реализацию K2 (Fir frontend).

Изменение произошло в структурах данных. Вместо старых структур (PSI + BindingContext) реализация K2 использует Fir (Frontend Intermediate Representation). Это семантическое дерево (дерево, в узлах которого к структуре кода добавлена семантическая информация). Оно представляет исходный код.

20e3402a3355374bcbf03cf3a99c238e.png

Как работает K2

Вместо того, чтобы как раньше отправлять PSI и BindingContext в Backend, в K2 на Frontend происходят дополнительные преобразования:

  1. Компилятор принимает raw PSI на входе (Parsing)

  2. Производит незаполненное raw Fir-дерево (Psi2Fir)

  3. Семантическая информация заполняет Fir-дерево (Resolution + Checking)

  4. Преобразует Fir в Ir (Fir2Ir)

  5. Передает Ir бэкенду 

В случае с K2, мы рассмотрим более сложный случай и пропустим те фазы, в которых K1 и K2 работают одинаково.

Для примера возьмем исходный код 

private val kotlinFiles = mutableListOf(1, 2, 3)

d5112a97d9581d6e903dc8ccb1e27fdf.png

Parsing

Начнем с уровня PSI (до этого K1 и K2 схожи):

a9b89fa7bb1d10a0c160659817323df8.png

В этом случае, реализация K1 просто генерировала бы дополнительные данные для отправки PSI на бэкенд. 

Psi2Fir

В отличие от нее, K2 компилирует этот промежуточный формат данных перед созданием Ir. В итоге получается Fir — это mutable-дерево, построенное из результатов парсера (PSI-дерева):

f651b10c054bc941fb3516f04817e779.png

Тут видно сходство между PSI и raw Fir.

1e88684e78583306c60d3baa8cc79948.png

Resolution

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

10cab42c14a33d2d388845583ddf0049.png

Все файлы в модуле проходят эти этапы одновременно. 

В это время выполняются такие действия, как:

  • Вычисление import«ов

  • Поиск идентификаторов класса для супертипов и наследников sealed классов

  • Вычисление модификаторов видимости

  • Вычисление контрактов функций

  • Выведение типов возвращаемого значения

  • Анализ тел функций и свойств

На стадии Resolution компилятор преобразует сгенерированное raw Fir, заполняя дерево семантической информацией:  

9ead4b70078c70c14a4d19c1d8d62f96.png

На этих этапах компилятор выполняет desugaring. Например, `when` можно заменить на `if` или `for` — на `while`. 

Раньше мы делали все это на Backend, но благодаря K2 то же самое можно делать на Frontend. 

Checking

6dc4ba2823a579a6e58c74e18ed50ed4.png

Мы пришли к последней стадии — стадии проверки. Здесь компилятор берет Fir и проводит диагностику на предмет предупреждений и ошибок. 

Если ошибки появились, данные отправляются плагину IDEA — именно он подчеркивает ошибку красной линией. Компиляция останавливается, чтобы не отправлять неверный код на бэкенд. 

Fir2Ir

Если ошибок нет

12867e5715f0fa5c6650a4a3715f9eb7.png

FIR трансформируется в IR

5f50f68104937a2fbcb4f6cb3a90c1a1.png

В итоге мы получаем входные данные для бэкенда

Визуальное сравнение результатов K1 и K2

57d17a34de9a0b9eef198ce7dcd0216e.png

Итого в K2 мы получили Fir (одну структуру данных вместо двух в K1 версии)

Backend

Цели улучшения Backend

Изначально при разработке Kotlin компилятор не использовал промежуточное представление (IR). Тк это необязательная стадия при разработке компиляторов. 

Каноничная архитектура компилятора

48b6a10e4b25df66253d762008826cc0.png

Отсутствие IR позволило Kotlin быстрее эволюционировать на ранних стадиях. Kotlin компилировался не только в JVM, но так же и в JS. JS высокоуровневый язык и он похож больше на Kotlin, чем на JVM байткод. Поэтому IR бы только тормозило разработку преобразования.

06b521b96545144de6567e249ac0d863.png

Но вскоре появился еще 1 native backend.

8633a44e35800c2fdc31298245f43fe8.png

Стало понятно что все 3 бэкенда могут использовать общую логику по трансформации и упрощению кода, что позволит поддерживать фичи для разных бекендов проще. Поэтому начиная с native-backend команда Kotlin решила использовать IR, сначала только для него самого.

2e914ef6feb4ab75068486290e87bc95.png

Итак, в качестве целей разработки новой версии можно выделить:

Необходимо отметить, что ускорение Backend не являлось целью. В отличие от ускорения Frontend-части.

Новая реализация Backend позволила расширять компилятор с помощью компиляторных плагинов. В частности реализация Jetpack Compose полагается на новую версию бекенда c использованием IR.

Как работает Backend

c7853463ed3b31b4e99c4698a6f4b4ab.png

Сейчас в Kotlin есть четыре реализации backend:  

  • JVM

  • JS

  • Native 

  • WASM

Какой бы бэкенд вы ни использовали, обработка в нем всегда начинается с генератора IR и оптимизатора. Затем выбранная конфигурация запускает сгенерированный код:

98be2d00820accf6e676d0332f0f4f90.png

Для примера выберем бэкенд JVM и продолжим работу с mutableList

IR

99190e0a2a44ffce6f512fe4a0aa4163.png

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

Можно рассматривать IR как представление кода в виде дерева, которое разработано специально для генерации кода target-платформы. В отличие от Fir на фронтенде, который проверяет смысл написанного кода.

IR lowering

Затем на IR выполняются оптимизации и упрощения это называется lowering.  Таким образом упрощается сложность Kotlin. Например в IR отсутствуют local и internal классы, они заменены отдельными классами со специальными именами. С помощью упрощений разным backend«ам не нужно бороться со сложностью языка Kotlin и реализация становится проще.

674cfd3bd2547984874c6d26b1b7c661.png

Также на этом этапе упрощаются операции, улучшается производительность и качество машинного кода, особенно с точки зрения многопоточности.

Target code

Теперь у нас есть сгенерированный код целевой платформы. В нашем примере это JVM байт-код. Далее он отправляется на виртуальную машину, где он и будет исполняться:

1e8495dda92dc53af904e3a5756b14ee.png

На этом задачи компилятора считаются выполненными.

Выводы

Что дает новый компилятор:

  • Единая структура данных Fir для представления кода и семантики

  • Возможность добавления плагинов компилятора

  • Упрощение поддержки новых языковых возможностей для разных target-платформ.

  • Улучшение производительности компилятора и IDE (плагин Kotlin в IDE использует Frontend компилятора). По результатам замеров JB:

    • ускорение компиляции на 94%

    • ускорение фазы инициализации на 488%

    • ускорение фазы анализа на 376%

В этой статье мы рассмотрели как работают разные версии компилятора Kotlin. Непосредственно в эту тему редко углубляются разработчики. Я надеюсь, что материал позволил заглянуть вам под капот и узнать, что же там происходит, когда вы каждый день собираете приложения на работе.

Дополнительные материалы

76da8fea949241a9417e3c6013d57c2a.png

© Habrahabr.ru