Насколько быстро парсится Kotlin и какое это имеет значение? JavaCC или ANTLR? Годятся ли исходники от JetBrains?
Сравниваем, фантазируем и удивляемся.
tl;dr
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
Такое время имеет следующие очевидные неудобства:
- потому что это плюс три секунды к запуску игры или приложения.
- во время разработки я использую горячую перегрузку шейдера и вижу результат сразу после изменения кода.
- я часто перезапускаю приложение и рад что оно стартует достаточно быстро (секунда-две).
Плюс три секунды на разогрев парсера — это неприемлемо. Конечно, сразу выяснилось что при последующих вызовах время парсинга падает до 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. Но не похоже чтобы они могли повлиять на производительность — я проверял скорость по мере добавления правил, и она не менялась заметно. Кроме того, я уже сделал гораздо больше чем мне было нужно и просто пытаюсь поделиться неожиданным результатом найденным в процессе.
Автор: Юрий Кравчик