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

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

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

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

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

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

Оглавление

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

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

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

  • меняет формат данных (compiling)

  • упрощает его (lowering)

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

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

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

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

  • Frontend

    • отвечает за построение синтаксического дерева (структуры кода) и добавление семантической информации (смысл кода)

  • Backend

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

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

Frontend

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

  • K1 (FE10-) 

  • K2 (Fir-)

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

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

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

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

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

Реализация K1

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

  • PSI (Programming Structure Interface)

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

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

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

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

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

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

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

Есть 2 фазы: 

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

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

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

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

fun two() = 2 

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

Parsing Phase

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

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

Пример:

Этапы Parsing phase

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

    • ( —> KtSingleValueToken.LPAR 

    • ) —>KtSingleValueToken.RPAR

    • = —>KtSingleValueToken.EQ

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

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

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

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

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

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

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

Resolution Phase

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

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

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

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

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

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

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

Типах

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

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

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

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

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

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

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

Производится выведение типов
val list1 = listOf(1, 2, 3)
val list2 = listOf("one", "two", "three")

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

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

public fun <T> listOf(vararg elements: T): List<T>

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

val list1 = listOf(1, 2, 3)

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

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

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

Этапы Resolution Phase

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

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

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

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

Проблемы K1

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

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

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

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

Реализация K2

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

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

Как работает 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)

Parsing

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

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

Psi2Fir

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

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

Resolution

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

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

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

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

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

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

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

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

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

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

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

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

Checking

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

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

Fir2Ir

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

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

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

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

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

Backend

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

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

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

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

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

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

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

  • переиспользование логики между разными бекендами

  • упрощение поддержки новых языковых возможностей

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

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

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

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

  • JVM

  • JS

  • Native 

  • WASM

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

 

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

IR

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

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

IR lowering

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

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

Target code

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

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

Выводы

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

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

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

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

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

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

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

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

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

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

Другие наши статьи по мобильной разработке:

Комментарии (13)


  1. Panzerschrek
    09.05.2024 05:40
    +1

    А почему LLVM не используется? Не очень то подходит для Kotlin? Или есть другие причины?


    1. Devchik
      09.05.2024 05:40
      +2

      Kotlin/Native включает в себя серверную часть на основе LLVM для компилятора Kotlin.


    1. equeim
      09.05.2024 05:40
      +3

      Вероятно потому что LLVM не очень подходит для компиляции в Java байткод. Бэкэнд для компиляции в нативный код использует LLVM.


  1. miksmiks
    09.05.2024 05:40

    Ничего особенного с точки зрения компиляторов. Одна из возможных архитектур в развитии. Показательна ссылка на Dragon book как одну из базовых и уже достаточно устаревших.


    1. LionZXY
      09.05.2024 05:40
      +7

      А расскажете какие сейчас актуальные тренды в компиляторостроении? Где об этом можно почитать?


  1. miksmiks
    09.05.2024 05:40
    +5

    Нет, я не эксперт здесь. Я понимаю, что это ирония. Я читаю классический курс по компиляторам - ничего особенного.

    Но действительно, архитектура K1 - совершенно классическая, архитектура K2, где синтаксическое дерево затем украшается семантикой, - тоже классическая. Выгода здесь понятно в рефакторинге. И два бонуса. Основной - это масштабирование на несколько целевых архитектур. Я думаю, ради этого всё затевалось. Второй - ускорение компиляции - это бонусом. Повторюсь, не случайно в списке литературы абсолютно стандартная и древняя Dragon book.

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

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


    1. MAX1993M Автор
      09.05.2024 05:40
      +2

      Выгода здесь понятно в рефакторинге. И два бонуса. Основной - это масштабирование на несколько целевых архитектур. Я думаю, ради этого всё затевалось. Второй - ускорение компиляции - это бонусом. 

      Языку уже 13 лет, цели изменились, много нового добавилось.

      Из-за появления новых целевых архитектур замедлялась разработка фичей языка. Поэтому JB в последних релизах и в том числе в 2.0 не выпускают ничего нового. Но в будущем новый компилятор открывает дорогу.

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

      Ничего особенного с точки зрения компиляторов. Одна из возможных архитектур в развитии.

      Чем плоха классическая архитектура, если она решает нужные задачи и уже опробована в действии много раз. Kotlin не экспериментальный язык, чтобы испытывать что-то особенное в компиляторе)

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

      Тк компилятор переписывался то я думаю что в JB анализировали разные подходы.

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

      Но, конечно, интересно, какие альтернативы JB рассматривали. Я эту информацию в открытых источниках не нашел.

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

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


  1. equeim
    09.05.2024 05:40

    94% - маркетинговая цифра из одного бенчмарка. В реальности сокращение времени компиляции не более 10-15%.


    1. MAX1993M Автор
      09.05.2024 05:40
      +1

      94% это ускорение по чистому билду. Помимо этого в статье с замерами есть и другие сценарии.

      Замеры проведены на реальных развивающихся опенсорсных проектах: Anki-Android, Exposed. Понятно, что эти проекты не эталонные, и будет интересно увидеть цифры от крупных компаний в будущем.


  1. rombell
    09.05.2024 05:40

    Ускорение на 94% - это ускорение в 16 раз! Кто-то сильно приукрашивает


    1. MAX1993M Автор
      09.05.2024 05:40
      +5

      Вы неправильно трактуете это число. Как трактовать эти числа можете почитать в статье.

      Также в этой статье привожу пример на официальные замеры JB. Там явно видно числа: 94% это не в 16 раз ускорение, у чуть меньше чем в 2.


      1. rombell
        09.05.2024 05:40
        +2

        Извините, но как раз я правильно толкую. Счёт ведётся от старого значения. было 100%, осталось (100-94)=6%, ускорение 100/6=16 раз. Правильно было сказать ускорение на 45%, но на 94% звучит гораздо маркетологичнее


      1. dkirienko
        09.05.2024 05:40
        +3

        Пусть прибыль в прошлом году была миллион, а в этом году - 2 миллиона. На сколько увеличилась прибыль? Все согласны, что на 100%. К миллиону нужно прибавить 100% от миллиона, получится два миллиона. Никто не скажет, что прибыль увеличилась на 50%.
        Если время сборки проекта составляло 10 секунд, а теперь составляет 5 секунд, на сколько оно уменьшилось? На 50%, нужно из 10 секунд вычесть 50% от 10 секунд, получится 5 секунд. А вовсе не на 100%, хотя по-видимому маркетологам JetBrains хотелось бы сделать из этих 50% целых 100%.