Компилируем Kotlin: JetBrains VS ANTLR VS JavaCC

cvtoy3lpibs4fm_1asygxcsdgse.jpeg
Насколько быстро парсится Kotlin и какое это имеет значение? JavaCC или ANTLR? Годятся ли исходники от JetBrains?

Сравниваем, фантазируем и удивляемся.


JetBrains слишком тяжело таскать за собой, ANTLR хайповый, но неожиданно медленный, а JavaCC ещё рано списывать.

Парсинг простого Kotlin файла тремя разными реализациями:

Имплементация Первый запуск 1000й запуск размер джара (парсера)
JetBrains 3254 мс 16,6 мс 35.3МБ
JetBrains (w/o analyzer) 1423 мс 0,9 мс 35.3МБ
ANTLR 3705 мс 137,2 мс 15.5МБ
JavaCC 19 мс 0,1 мс 1.6МБ


Одним погожим солнечным деньком…


я решил собрать транслятор в GLSL из какого-нибудь удобного языка. Идея была в том, чтобы программировать шейдеры прямо в идее и получить «бесплатно» поддержку IDE — синтаксис, дебаг и юнит-тесты. Получилось действительно очень удобно.

С тех пор осталась идея использовать Kotlin — в нём можно использовать имя vec3, он более строг и в IDE с ним удобнее. Кроме того — он хайповый. Хотя с точки зрения моего внутреннего менеджера это всё недостаточные причины, но идея столько раз возвращалась, что я решил от неё избавиться просто реализовав.

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

JetBrains


Ребята из JetBrains выложили код своего компилятора на гитхаб. Как им пользоваться можно подсмотреть тут и тут.

Сначала я использовал их парсер вместе с анализатором, потому что для трансляции в другой язык — необходимо знать какой тип у переменной без явного указания типа val x = vec3(). Тут тип для читателя очевиден, но в AST эту информацию не так просто получить, особенно когда справа другая переменная, или вызов функции.

Здесь меня постигло разочарование. Первый запуск парсера на примитивном файле занимает 3с (ТРИ СЕКУНДЫ).

Kotlin JetBrains parser
first call elapsed : 3254.482ms
min time in next 10 calls: 70.071ms
min time in next 100 calls: 29.973ms
min time in next 1000 calls: 16.655ms
Whole time for 1111 calls: 40.888756 seconds

Такое время имеет следующие очевидные неудобства:

  1. потому что это плюс три секунды к запуску игры или приложения.
  2. во время разработки я использую горячую перегрузку шейдера и вижу результат сразу после изменения кода.
  3. я часто перезапускаю приложение и рад что оно стартует достаточно быстро (секунда-две).


Плюс три секунды на разогрев парсера — это неприемлемо. Конечно, сразу выяснилось что при последующих вызовах время парсинга падает до 50 мс и даже до 20 мс, что убирает (почти) из выражения неудобство №2. Но два остальных никуда не деваются. К тому же, 50 мс на файл — это плюс 2500 мс на 50 файлов (один шейдер — это 1–2 файла). А если это Android? (Тут мы пока говорим только про время.)

Обращает на себя внимание сумасшедшая работа JIT. Время парсинга простого файла падает с 70 мс до 16 мс. Что означает, во первых — сам JIT потребляет ресурсы, а во вторых — на другой JVM результат может сильно отличаться.

В попытке выяснить откуда такие цифры, нашёлся вариант — использовать их парсер без анализатора. Ведь мне нужно всего лишь расставить типы и сделать это можно относительно легко, в то время как JetBrains анализатор делает что-то гораздо более сложное и собирает гораздо больше информации. И тогда время запуска падает в два раза (но почти полторы секунды это всё равно прилично), а время последующих вызовов уже гораздо интереснее — с 8 мс в первых десяти, до 0.9 мс где-то к тысяче.

Kotlin JetBrains parser (without analyzer) (исходник)
first call elapsed : 1423.731ms
min time in next 10 calls: 8.275ms
min time in next 100 calls: 2.323ms
min time in next 1000 calls: 0.974ms
Whole time for 1111 calls: 3.6884801 seconds

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

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

ANTLR


На JavaCC парсера не нашлось, а вот на хайповом ANTLR, ожидаемо, есть.

Но вот что было неожиданно — так это скорость. Те же 3с на прогрузку (первый вызов) и фантастические 140 мс на последующие вызовы. Тут уже не только первый запуск длится неприятно долго, но и потом ситуация не исправляется. Видимо, ребята из JetBrains, сделали какую-то магию, позволив JIT так оптимизировать их код. Потому что ANTLR совсем не оптимизируется со временем.

Kotlin ANTLR parser (исходник)
first call elapsed : 3705.101ms
min time in next 10 calls: 139.596ms
min time in next 100 calls: 138.279ms
min time in next 1000 calls: 137.20099ms
Whole time for 1111 calls: 161.90619 seconds

JavaCC


В общем, с удивлением отказываемся от услуг ANTLR. Парсинг не должен быть таким долгим! В грамматике Котлина нет каких-то космических неоднозначностей, да и проверял я его на практически пустых файлах. Значит, настало время расчехлить старичка JavaCC, закатать рукава, и всё таки «сделать самому и как надо».

На этот раз цифры получились ожидаемыми, хотя в сравнении с альтернативами — неожиданно приятными.

Kotlin JavaCC parser (исходник)
first call elapsed : 19.024ms
min time in next 10 calls: 1.952ms
min time in next 100 calls: 0.379ms
min time in next 1000 calls: 0.114ms
Whole time for 1111 calls: 0.38707677 seconds

Внезапные плюсы своего парсера на JavaCC
Конечно, вместо написания своего парсера хотелось бы использовать готовое решение. Но существующие имеют огромные недостатки:

— производительность (паузы при чтении нового шейдера недопустимы, как и три секунды разогрева на старте)
— огромный рантайм котлина, я даже не уверен можно ли парсер с его использованием упаковать в финальный продукт
— кстати, в текущем решении с Groovy та же беда — тянется рантайм

В то время как получившийся парсер на JavaCC это

+ отличная скорость и на старте и в процессе
+ всего несколько классов самого парсера

Выводы


JetBrains слишком тяжело таскать за собой, ANTLR хайповый, но неожиданно медленный, а JavaCC ещё рано списывать.

Парсинг простого Kotlin файла тремя разными реализациями:

Имплементация Первый запуск 1000й запуск размер джара (парсера)
JetBrains 3254 мс 16,6 мс 35.3МБ
JetBrains (w/o analyzer) 1423 мс 0,9 мс 35.3МБ
ANTLR 3705 мс 137,2 мс 15.5МБ
JavaCC 19 мс 0,1 мс 1.6МБ


В какой-то момент, я решил посмотреть на размер джара со всеми зависимостями. JetBrains велик вполне ожидаемо, а вот рантайм ANTLR удивляет своим размером.

Размер джара как таковой важен, конечно, для мобилок. Но и для десктопа имеет значение, т.к., фактически, означает количество дополнительного кода, в котором могут водиться баги, который должна индексировать IDE, который, как раз, и влияет на скорость первой загрузки и скорость разогрева. Кроме того, для сложного кода нет особой надежды транслировать на другой язык.
Я не призываю считать килобайты и ценю время программиста и удобство, но всё же об экономии стоит задумываться, потому что именно так проекты и становятся неповоротливыми и трудно поддерживаемыми.

Ещё пара слов об ANTLR и JavaCC

Серьёзной фичей ANTLR является разделение грамматики и кода. Это было бы хорошо, если бы за это не нужно было так дорого платить. Да и значение это имеет только для «серийных разработчиков грамматик», а для конечных продуктов это не так важно, ведь даже существующую грамматику всё равно придётся прошерстить чтобы написать свой код. Плюс, если мы сэкономим и возьмём «стороннюю» грамматику — она может быть просто неудобна, в ней всё равно нужно будет досконально разбираться, преобразовывать дерево под себя. В общем, JavaCC, конечно, смешивает мух и котлеты, но такое ли большое это имеет значение и так ли это плохо?

Ещё одной фишкой ANTLR является множество target платформ. Но тут посмотреть можно с другой стороны — код из под JavaCC очень простой. И его очень просто… транслировать! Прямо с вашим кастомным кодом — хоть в C#, хоть в JS.

P.S.


Весь код находится тут github.com/kravchik/yast

Результатом парсинга у меня является дерево построенное на YastNode (это очень простой класс, фактически — мапа с удобными методами и айдишником). Но YastNode — это не совсем «сферическая нода в вакууме». Именно этим классом я активно пользуюсь, на его основе у меня собрано несколько инструментов — типизатор, несколько трансляторов и оптимизатор/инлайнер.

JavaCC парсер пока содержит не всю грамматику, осталось процентов 10. Но не похоже чтобы они могли повлиять на производительность — я проверял скорость по мере добавления правил, и она не менялась заметно. Кроме того, я уже сделал гораздо больше чем мне было нужно и просто пытаюсь поделиться неожиданным результатом найденным в процессе.

© Habrahabr.ru