Регулярные выражения — широко используемый способ задания конечных автоматов для поиска текста. Применяются они с давних времён и повсеместно. Они удобны тем, что их запись весьма краткая и лаконичная. Но обратной стороной их удобства является достаточно низкая производительность конечных автоматов, построенных по регулярным выражениям. Проблема в том, что в большинстве реализаций этот конечный автомат строится на лету — во время исполнения программы, что весьма ограничивает скорость его работы. Возникает вопрос — как можно ускорить построение конечных автоматов по регулярным выражениям? У меня как-то раз возникла идея, как это можно сделать — для этого можно применить настоящий компилятор, который может генерировать максимально эффективный машинный код. Эту идею я решил опробовать на практике, используя библиотеку LLVM как компилятор/оптимизатор для регулярных выражений. Что из этого вышло, будет изложено ниже в данной статье. Для начала я попытался поискать, делал ли кто-то такое же до меня? Нашёл пару заброшенных репозиториев в зачаточном состоянии, но не более того. Отсутствие аналогов меня озадачило, но, с другой стороны, подстегнуло к действию. Не часто ведь выпадает шанс быть первопроходцем! Из не совсем аналогов я нашёл библиотеку Compile time regular expressions. Эта библиотека генерирует match функции через constexpr-шаблонную магию C++. Минус данной библиотеки в том, что она заточена строго под C++. Универсальный LLVM генератор позволил бы использовать его в любых других компилируемых языках. В качестве эксперимента созданы библиотека под названием RegPanzer и компилятор на её основе, позволяющие скомпилировать в нативный код match функцию для заданного регулярного выражения. В качестве диалекта регулярных выражений выбран диалект, использующийся в библиотеке PCRE. Стандарт регулярных выражений ECMAScript (использующийся также в C++) не был выбран потому, что он имеет меньше возможностей. Поддержаны ряд ключевых возможностей регулярных выражений — сопоставление с заданными символами, диапазонами, работа с повторяющимися последовательностями (ленивыми, жадными, ревнивыми), альтернативы, захват групп, сопоставление с ранее найденным фрагментом, незахватывающий просмотр (назад и вперёд), утверждения начала/конца строки, вызов подпроцедур, атомарные группы, незахватывающие группы. Пока что не реализован ряд (субъективно) второстепенных особенностей — именованные группы, рекурсивные группы, классы символов Юникода, сброс начала результата, комментарии, DEFINE и ряд других. Реализацию их я посчитал пока что несущественной для целей проверки концепции. В будущем, если будет принято решение развивать данную библиотеку, не составит труда реализовать недостающий функционал. Регулярное выражение есть последовательность элементов, каждый из которых имеет квантификатор количества. Элементы это или конкретные символы, или набор из нескольких возможных символов, или любой символ, или группа или ещё что-то. Квантификаторы количества — один, один или ноль, больше нуля, один или больше нуля, определённый диапазон. Существуют также альтернативы (или элемент 1, или элемент 2, или элемент 3 и т. д). Примеры регулярных выражений: Квантификаторы количества также могут иметь модификаторы — ленивый (суффикс ?), жадный (по-умолчанию) или ревнивый (суффикс +). О том, на что они влияют, см. ниже. Поиск сопоставления текста регулярному выражению реализуется через набор вложенных вызовов функций. Функция для элемента n проверяет текущий символ в строке на соответствие и, если оно найдено, вызывает функцию для элемента n+1. Функции для элементов альтернатив вызывают по очереди функции для всех возможных последующих элементов, возвращаясь после нахождения успешного сопоставления. Именно поэтому функции сопоставления вызывают друг друга, а не вызываются из одного места последовательно — чтобы иметь возможность откатиться к альтернативе в случае неудачи. Функции для элементов с квантификатором количества, отличным он нуля, вызывают рекурсивно сами себя. Функции для элементов с ленивым квантификатором сперва вызывают функцию для следующего элемента, а потом только, в случае неудачи, вызывают сами себя. Функции с жадным квантификатором наоборот — сначала вызывают рекурсивно себя, а в случае неудачи — функции следующего элемента. Функции с ревнивым квантификатором работают без рекурсии. Они сначала в цикле вызывают функцию сопоставления для элемента, пока она успешна, а потом (без всякого отката) вызывают функции следующего элемента. Графически всё вышеизложенное можно изобразить следующим образом: abc:  ab+c:  a(bc)*:  a(bс|def):  a[b-f]:  ([a-f])2\1:  Библиотека RegPanzer, в общих чертах, работает следующим образом: строка исходного регулярного выражения разбирается в первое промежуточное представление (линейное). После этого происходит преобразование во второе промежуточное представление (в виде графа). Далее по графу строятся уже LLVM match функции. Также есть вспомогательная функция для непосредственного поиска сопоставлений по графу регулярного выражения. Данная функция используется в тестах для верификации сгенерированных LLVM функций. Как же в итоге выглядит сгенерированный LLVM код? Об этом пойдёт речь далее. Каждому узлу графа соответствует своя LLVM функция. Каждой функции на вход передаётся на вход структура состояния. В простейшем виде это исходная строка и текущая позиция в ней: Каждая функция возвращает значение типа bool, true — в случае успешного нахождения сопоставления, false — в случае неудачи. Также генерируется одна входная функция, которая осуществляет инициализацию структуры состояния, осуществляет итерацию по входной строке, пока не будет найдено соответствие, заполняет структуры с результатом и т. д. Данная функция весьма объёмная и код её не особо интересен, так что опустим его и перейдём к самому главному. Итак, простейший пример — функция для одного символа — Q Данная функция проверяет, что не достигнут конец строки, если это так, вычитывает символ и сравнивает его с требуемым. В случае успеха она передаёт управление следующей функции, которая тут есть функция для конца выражения, возвращающая всегда true. Пример посложнее — [a-z]6 Первая функция тут работает как в предыдущем примере, но проверяет символ не на точное значение, а не принадлежность диапазону. Также она передаёт управление следующей функции (не функции true для конца выражения). Пример с альтернативами — (0|1)q Здесь логика уже чуть более сложная. Функция альтернатив проверяет первую ветку, предварительно сохранив состояние. Если результат первой ветки есть true — она возвращается. Иначе она восстанавливает состояние и вызывает функцию для другой альтернативы. Важно отметить, что из функции альтернатив вызываются функции для символов 0 или 1. А эти функции обе вызывают функцию для символа q. Тем самым граф вызовов образует ромб. Простейший пример последовательности — 0* Как видно, последовательность реализована через функцию альтернатив. При этом функция альтернатив опосредованно вызывает сама себя. Первый вызов в функции альтернативы — элемент последовательности, второй — элемент после последовательности (завершение выражения в данном случае). Если сделать последовательность ленивой, вызовы будут в другом порядке — сначала для элемента после последовательности, а потом для самого элемента последовательности. Пример ревнивой последовательности — a+ Как было уже отмечено ранее, ревнивые последовательности реализуются без рекурсии, а через цикл. С одной стороны это позволяет убыстрить поиск соответствия, с другой стороны, в некоторых случаях это не даёт найти некоторые соответствия. Пример просмотра назад — ([a-f])2\1 В примере выше видно гораздо больше конструкций. В функцию состояния добавились ещё поля, последнее из них — массив групп. Функции group_start и group_end сохраняют начало/конец группы для последующего её использования. Функция back_reference ищет сопоставление с ранее найденной группой. Вышеприведённые примеры — код, непосредственно сгенерированные библиотекой RegPanzer. Компиляция его в машинный код даст не самый быстрый код, который будет проигрывать в производительности даже некоторым regex библиотекам, не использующим генерацию машинного кода. Чтобы получить максимальную производительность, этот код надо оптимизировать. Базовую оптимизацию выполняет библиотека LLVM. В число её оптимизаций входят устранение излишней работы с памятью и замена её обращением к регистрам, встраивание функций, разворачивание циклов, превращение хвостовой рекурсии в цикл и многое другое. По моему опыту, важнейшие из этих оптимизаций, это оптимизации встраивания вызовов функций. В простейших линейных регулярных выражениях с этим нет проблем, так как в них цепочка вызовов ациклична, и оптимизатор превращает цепочку вызовов в набор последовательных операций внутри одной функции. В регулярных выражениях с альтернативами проблемы тоже нету, ромбовидные вызовы легко можно заменить на jmp инструкции. Также нету проблемы с ревнивыми последовательностями, ибо в них изначально нету циклических вызовов, а есть только цикл. Оптимизатор также может оптимизировать ленивые последовательности, ибо (как отмечено выше) рекурсивный вызов самой себя функция для последовательности делает последним, а значит такой вызов есть хвостовой. Единственный случай, с которым оптимизатор LLVM не справляется — это жадные последовательности. Их вызов никак не может быть преобразован в хвостовой, т. к он не последний и перед этим вызовом надо сохранять структуру состояния. Если LLVM не справляется с таким, подобные оптимизации придётся реализовывать самому. Наиболее очевидный способ избавления от рекурсии в поиске сопоставления для последовательностей — заменить рекурсию циклом. Цикл уже и так генерируется для ревнивых последовательностей. Спрашивается, а нельзя ли сделать цикл и для жадных последовательностей? Ответ на этот вопрос положительный — да, можно, в некоторых случаях. Каковы же эти случаи? Это те случаи, когда рекурсивный откат назад не возможен таким образом, чтобы он дал соответствие для всего выражения. Не возможен он например, если никакая последовательность символов, которая может быть элементом последовательности, не может быть префиксом выражения после последовательности. Этот критерий достаточно полон, но не практичен, ибо сложно доказывать невозможность совпадения для целых частей регулярных выражений. Практически его можно заменить на более простой, но менее полный критерий — никакой символ, который может быть началом элемента последовательности не может быть началом элемента после последовательности. Таким образом, генератору match функций достаточно собрать множество символов начала элемента последовательности и множество символов элемента после неё, и в случае, если эти множества не имеют общего элемента — сделать последовательность ревнивой. Примеры регулярных выражений. где последовательность может быть сделана ревнивой таким образом: Примеры тех случаев, когда такая оптимизация не применима: В случае, когда описанная выше оптимизация не применима, всё-же иногда можно заменить рекурсию циклом. Напомню, что рекурсия необходима в том числе для того, чтобы хранить стек состояний для каждой итерации последовательности, чтобы иметь возможность откатиться назад. Но что, если заменить целый стек состояний одним значением? Возможно ли это? Таки да, это возможно, но в определённых обстоятельствах. Во-первых, это возможно, когда элемент последовательности не модифицирует в структуре состояния ничего кроме указателя позиции в строке — не заполняет группы, не вызывает подпроцедур, и т. д. Во-вторых, это возможно, когда элемент последовательности модифицирует этот самый указатель позиции определённым образом, а именно изменяет его всегда на строго одинаковое значение, т. е. всегда имеет одну и ту же (фиксированную) длину. В таком случае можно заменить рекурсивный вызов двумя циклами. В первом цикле производится поиск сопоставления для очередного элемента последовательности. Если сопоставление не найдено — цикл завершается. При этом сохраняется счётчик цикла. Во втором цикле вызывается функция для поиска сопоставления для оставшейся части выражения, пока таковое не будет найдено. При этом на каждой итерации цикла счётчик уменьшается, указатель текущей позиции в строке уменьшается на фиксированный шаг. Примеры регулярных выражений, где такая оптимизация работает: Где такая оптимизация не работает: Удивительно, но оптимизатор LLVM плохо справляется со случаем, когда в регулярном выражении встречается фиксированная последовательность символов. Если просто сгенерировать функции для отдельных символов, результирующий код будет выглядеть примерно следующим образом (псевдокод): Оптимизатор LLVM не может выбросить проверки на конец строки для каждого символа. Поэтому пришлось вручную реализовать следующую оптимизацию: последовательность фиксированных символов объединяется в эталонную строку. При поиске сопоставления сначала проверяется, что входная строка не короче эталонной, и если это условие истинно, проверяется, что строка с текущей позиции совпадает с эталонной строкой. Такая оптимизация в пределе уменьшает вдвое количество проверок на символ. В результате, код выше превращается в следующий (псевдокод): Я создал ряд тестов производительности на основе библиотеки google benchmark. Каждый тест запускается для нескольких различных библиотек, для сравнения результата. Среди них собственно RegPanzer, регулярные выражения стандартной библиотеки С++ (от gcc 7), llvm::Regex (вспомогательная библиотека регулярных выражений, используемая внутри LLVM), PCRE в двух вариантах — с JIT и без него. Каждый тест направлен на проверку работы с определёнными типовыми видами регулярных выражений. На вход каждому тесту подаётся весьма длинный текст, состоящий из набора "слов", разделённых пробелами. Для каждого теста при помощи генератора псевдослучайных чисел генерируется такой текст, его параметры (размеры слов, количество слов, символы в словах) различны для каждого теста. Итак, сводная таблица с описанием тестов и результатом: Прочерком указаны результаты тех тестов, которые данная библиотека не поддерживает. Регулярные выражения скомпилированы библиотекой RegPanzer с уровнем оптимизации O2 и целевым процессором по умолчанию. Как можно заметить, библиотеки без компиляции в машинный код работают существенно медленнее, чем те, что используют какую-либо компиляцию. На удивление оказалось, что PCRE с JIT в большинстве случаев работает заметно быстрее RegPanzer. Но есть и тесты, где RegPanzer несколько быстрее, среди них тест на рекурсию с регулярными выражениями. Моё предположение, почему так происходит, следующее: в библиотеке PCRE JIT мог весьма долго настраиваться под случай с регулярными выражениями, тогда как библиотека LLVM оптимизирует код так же, как и любой другой обобщённый код. Внимательный осмотр выходного ассемблерного кода показал, что он более-менее оптимален — не содержит вызовов функций или излишней работы с памятью. Предположу, что оптимизировать код ещё сильнее (до уровня PCRE JIT или лучше) можно, например, скомпилировав его с использованием информации от профилировщика. Но пока я так не пытался делать. Хочу также отметить, что результат RegPanzer на десятки процентов изменяется в зависимости от опций компиляции (O2/O3, целевой процессор и т. д.), как в лучшую, так и в худшую сторону. При этом ассемблерный код изменяется незначительно — в паре места одна инструкция заменяется другой или инструкции переставлены местами. Удивительно, что столь незначительные изменения дают столь существенный результат. Библиотека RegPanzer позволяет весьма сильно ускорить поиск сопоставлений с использованием регулярных выражений, в сравнении с библиотеками, не генерирующими бинарный код. Где и как её (или ей подобную библиотеку) можно использовать? Идеально подходит эта библиотека для тех случаев, когда нужно использовать весьма сложные регулярные выражения, которые при этом не меняются — являются константами времени сборки программы. В таком случае можно просто скомпилировать регулярное выражение в бинарный код и скомпоновать его со своей программой. Для тривиальных случаев, когда регулярное выражение представляет простую последовательность символов с минимумом ветвлений, использование данной библиотеки является перебором — проще вручную написать функцию, выполняющую аналогичный поиск. Также данная библиотека может быть полезна в тех случаях, когда регулярные выражения не совсем константны, но в то же время каждое регулярное выражение используется значительное количество раз или используется для поиска в большом объёме данных (гигабайты). В таком случае можно скомпоноваться с данной библиотекой и генерировать функции поиска сопоставлений на лету. Ценой такого подхода является разбухание исполняемого файла на два-три десятка мегабайт — для LLVM библиотек, выполняющих оптимизации и компиляцию в бинарный код. Но, вышеназванных случаях это может быть оправдано. Ещё одно из применений — встраивание данной библиотеки в компилятор какого-либо языка, который уже и так использует библиотеку LLVM для оптимизации и кодогенерации. В таком случае можно будет встроить регулярные выражения (константы времени компиляции) в сам целевой язык. В этом случае данная библиотека будет ещё одним, не самым большим, компонентом компилятора. При встраивании сгенерированной match функции в код программы на целевом языке также возможно межпроцедурная оптимизация, что может ещё больше ускорить выполнение поиска в месте с обработкой его результата. На ум также приходит использование данной библиотеки как системной библиотеки регулярных выражений (на замену PCRE). При должной её настройке и доработке она вполне могла бы послужить в такой роли, но только если удастся убыстрить её. RegPanzer на GitHub. Документация по формату регулярных выражений PRCE. Описание используемого алгоритма поиска