Использование ANTLR на полную
ANTLR наиболее хорошо работает примерно в пределах 1 кб — 10 кб, файлы большего размера не будут анализироваться данной библиотекой с максимальным быстродействием. Так же отладка работы на больших файлах становится почти невозможной. Основной причиной, по которой необходимо держать объем входных данных для парсера в заданных пределах — алгоритмы прогнозирования ветвлений для ANTLR активно используют стек, соответственно любыми способами необходимо избавляться от циклов в грамматике, когда одно правило вызывает само себя через множество других правил в том числе с определенными условиями активации такой связи, рассмотрим на примере:
var f = fun a(b) { b = 10; return b; }
Данный код можно описать следующей грамматикой:
start: stmt* EOF; stmt: varDecl | funDecl | expr ';' | 'return' expr ';'; varDecl: 'var' id '=' expr; expr: id ('=' expr)? | number; funDecl: 'fun' id '(' id* ')' '{' stmt* '}'
Легко заметить, что stmt вызывается внутри правила funDecl, что формирует цикл, который в реальных приложениях может привести к тому, что файл в 100 кб потребует хипа на 4 ГБ ОЗУ для максимальной производительности, а то и вовсе возможности работать, что является совершенно недопустимой ситуацией.
Одним из способов для преодоления такого недостатка является добавление специальных модификаций в процессе создания токенов, а именно — свертка блоков {} в токен, с обработкой этих токенов позднее в новом парсере, на вход которого будут переданы свернутые токены.
Для этого в ANTLR имеется возможность переопределить метод org.antlr.v4.runtime.Lexer#nextToken лексера, где можно реализовать функционал свертки:
override def nextToken(): Token = super.nextToken() match { case x: RScanToken if x.getType == foldOpen => buildFoldToken(x) case x: Token => x }
Грамматика должна быть дополнена:
startJavaScript : HashBangLine? jsFileBody? EOF ; startFoldBlock : block EOF ; block : LBRACE stmtList? RBRACE | FoldBlock // сохраняем информацию в специальный узел, // что бы потом вызвать startFoldBlock ;
При таком подходе содержимое свертки не будет обрабатываться лишний раз, так как рекурсивный алгоритм будет поглощать токены и сохранять их.
Скорость обработки вырастает от 2 до 10 раз, особенно заметна разница становится на минифицированных js файлах.
Влияние на работу ANTLR предлагаемой оптимизации на примере нескольких javascript файлов:
| Файл | Размер, кб | До оптимизации, мс | После оптимизации, мс |
|---|---|---|---|
| normalize-and-load-metadata.js | 10 | 1839 | 1372 |
| rewrite-live-references.js | 8 | 899 | 528 |
| dom.umd.min.js | 130 | 43852 | 6607 |
| react.pure.umd.min.js | 139 | 51668 | 6495 |
| tsserver.js | 8151 | 362857 | 117787 |
Без оптимизации работа осуществляется в один поток, с оптимизацией, если возможно, то обработка параллелится.
Запуск осуществлялся на машине с 64 гб ОЗУ, Intel® Core(TM) i7-9700K CPU @ 3.60GHz, OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.4+11).
Ключи запуска JVM: -Xmx4G -XX:+UseG1GC -XX:MaxHeapFreeRatio=30 -XX:MinHeapFreeRatio=10
Для файла tsserver.js ключ -Xmx32G, при этом потребление памяти достигло 22 гб! В то время как минифицированные файлы до 200 кб с оптимизацией анализируются с лимитом хипа в 128 мб в параллельном режиме без необходимости сборщику мусора оказывать нагрузку на процессор, максимум 2%!
Профилирование
ANTLR поддерживает профилирование работы парсера/лексера в рантайме, что бы включить, нужно вызвать метод: org.antlr.v4.runtime.Parser#setProfile до выполнения каких-либо правил. Далее можно получить всю информацию, например таким способом:
private def printProfileInfo(parser: AbstractRScanParser, tokenStream: TokenStream): Unit = { // do the actual parsing val parseInfo = parser.getParseInfo val atn = parser.getATN for (di <- parseInfo.getDecisionInfo if di.ambiguities.size() > 0) { val ds = atn.decisionToState.get(di.decision) val ruleName = parser.ruleName(ds.ruleIndex) log.debug("Ambiguity in rule '" + ruleName + "' -> {}", di) log.debug("=========================") log.debug(tokenStream.getText) log.debug("=========================") } }
Очевидным плюсом при таком способе является уменьшение объема данных, которые вам необходимо анализировать, так как свертки можно заменить на {} и работать всегда в локальной свертке, что значительно ускоряет процесс оптимизации грамматики.
Заключение
В данной статье предложен метод оптимизации работы формальных грамматик, реализованных с помощью библиотеки ANTLR, хотя сам подход можно попытаться применить и к другим библиотекам. Особенностью оптимизации является уменьшение количества переходов вперед для валидации входного потока токенов. Так же она позволяет анализировать большие файлы (более 1 мб) при затратах памяти в 512 мб при однопоточной обработке, в то время как отсутствие оптимизации будет требовать в 50 раз больше памяти для быстрой работы с использованием той же самой грамматики.
Исходный код доступен на гитхабе. Данный проект сделан для демонстрации работы оптимизации. Проект упрощен максимально, начать изучение рекомендуется в класса GrammarTester, для него сделана конфигурация запуска, вам останется прописать только путь к папке с входными данными. Обратите внимание на ключ rscan.tester.cache.dir, в эту папку будут писаться временные файлы, успешный анализ файла закрепляется в этой папке и не позволит парсеру анализировать его второй раз — удобно для исправления ошибок. В данный момент грамматика протестирована на 17 тыс файлах из node_modules, создаваемого для базового приложения react. Игнорировались только вложенные node_modules.
В следующей статье будут рассмотрены варианты организации данных и модель R (Отражение) для оптимального представления исходных данных в виде объектов анализа с минимальным потреблением памяти, возможностью реализовать алгоритмы поиска элементов со сложностью О(1), при этом иметь возможность сохранять на диск в сериализованном виде все объекты, а так же юнит-тестировать части инструмента.
ссылка на оригинал статьи https://habr.com/ru/post/487716/
Добавить комментарий