- PVSM.RU - https://www.pvsm.ru -
В ноябре прошлого (уже) года, Hola объявила конкурс по программированию почтовых фильтров на js [1], и недавно опубликовала его результаты [2].
Я разделил второе место с Ильей Макаровым, и сейчас я расскажу…
Решив принять участие в конурсе и задумавшись над поставленной задачей, я довольно быстро осознал, что заданные шаблоны email'ов могут быть преобразованы в регулярные выражения простой заменой '*' на '.*' и '?' на '.' и экранированием всех специальных символов. На этом можно было и успокоиться, как и поступил Надав Ивги, получивший специальный приз за самое короткое решение [3].
Но это решение обладает фатальным недостатком — сложностью O((кол-во правил)*(кол-во сообщений)), так как каждое сообщение нужно проверить на соответствие каждому правилу.
Есть лучший путь. Во первых, две регулярки для полей .from и .to, можно соединить [4] в одну, отделив их друг от друга неиспользуемым символом (скажем 'x1F', так как по условию используются только символы от 'x20' до 'x7F' включительно), и то же самое проделывать с соответствующими полями сообщений:
{from: 'john.doe@foo.org', to: '*@hola.*', action: 'act1'}
->
{signature: 'alex@foo.orgx1F*@hola.*', action: 'act1'}
Это уменьшает количество необходимых тестов вдвое.
Во вторых, эти регулярки можно скомпилировать в один детерминированный конечный автомат (DFA) [5], сложность работы которого не зависит от их колчества. Здесь стандартный RegExp [6] нам уже не поможет.
Именно это и делала первая версия [7] моего решения.
Успешно протестировав её на данных из задания я решил автоматизировать процесс и реализовал генератор исходных данных и сравнение результатов с reference implementation [8]. Как оказалось не зря, в процедуре минимизации DFA обнаружился баг, и я временно исключил её из алгоритма [9] (её наличие не влияет на корретность, только производительность).
Мой скрипт долбился в reference implementation с масимально возможно частотой, и через пару дней Hola ввели ограничение на частоту запросов. Хех. Я пожал плечами и ввел задержку между запросами [10]. Тестирование стало проходить медленнее, но жить было можно.
Тут началась оптимизация. Первая идея состояла в компиляции DFA в огромный switch на js [11], как это делает lex [12] и другие подобные инструменты.
Реализовав такой кодогенератор, я понял что не имею возможности оценить, улучшил ли я результат, и что для этого мне нужен куда более объемный набор тестовых данных. Я решил, что не смогу так просто сгенерировать датасет со вменяемыми статистическими характеристиками, и неплохо бы использовать реальный. Довольно быстро я нашёл Enron Email Dataset [13] — email архив обанкротившейся компании с соответствующим названием. Пара скриптов на Python [14] извлекли сообщения и сгенерировали из их адресов правила, и бенчмарк был готов.
Я запустил его и… подрвался на комбинаторной мине [15]. Дело в том, что алгоритм построения DFA [16] имеет экспоненциальную сложность (O(2^(кол-во состояний недетерминированного конечного автомата (NFA)))) и я недооценил насколько это ужасно. Я не мог дождаться конца завершения построения автомата для всего лишь двадцати правил. Вместо того чтобы решать эту алгоритмическую проблему, я потратил время на оптимизацию и без того высокопроизводительной части системы. Воистину, premature optimization is the root of all evil [17].
Ощутив просветление, первым делом я разделил .from и .to обратно, получив таким образом один автомат оперирующий только на .from для всех правил, и по одному автомату оперирующему на .to для каждой группы правил, .from которых приводят к одному и тому же конечному состоянию первого автомата:

Это уменьшило размеры каждого NFA как минимум вдвое, а значит и существенно уменьшился размеры соответствующих DFA. Производительность поднялась на порядок, теперь уже можно было дождаться построения автомата для сотни правил.
Но это всё равно было слишком долго. Пришло понимание, что нужно менять концепцию, а где-то через неделю пришло и озарение: не нужно строить весь детерминированный автомат сразу, можно строить его лениво, по одному состоянию за шаг. Реализация этой концепции [18] меня сильно удивила: она была в несколько раз быстрее предудущей версии на тех же входных данных, более того, она могла без труда прожёвывать входные данные куда большей размерности. Видимо большая часть состояний полного DFA не посещаются. Я даже заново распарсил датасет Enron'а, чтобы извлечь больше данных.
Я решил, что это то, что мне нужно, и принялся за оптимизацию.
Я попробовал несколько разных вещей, опишу те, что дали ощутимый результат.
Не замерял, но имею основания полагать, что времени экономит больше всего остального. Используется в двух местах:
filter сообщения, имеющие одинаковые массивы действией, на самом деле ссылаются на один и тот же объект массива. Массивы действий не заполняются заново для каждого нового сообщения.
Потратил на это больше всего времени. Суть такова: нужно эффективное отображение из множества Object'ов (состояний NFA) в другой Object (соcтояние DFA). Так как в js нету нормальных хештаблиц c нестроковыми ключами, пришлось изворачиваться.
Сделал следующее: в каждое состояние NFA поместил уникальный инт .id при создании NFA (простым инкрементом). Имея множество таких состояний, кладу их .id в Buffer [21], сортирую, и интерпретирую как строковый ключ [22] с помощью .toString() [23].
NFA, получающийся из шаблонов email'ов, имеет особенности:
.id) таким образом, что построенные в дальнейшем множества оных будут уже отсортированы, убирая необходимость сортировки для построения строкового ключа (описано выше).id NFA состояний выкидывается вообще всегда.В обоих случаях можно создать массив и буфер максимально необходимых размеров [26] при создании DFA и многократно их использовать без перевыделения памяти.
К этому моменту у меня заканчивались возможности для оптимизации, а полной уверенности в корректности решения не было. Чтобы исправить это досадное упущение, я написал ещё один скрипт на питоне [27], который медленно, за несколько дней, прогнал маленький кусочек датасета Enron'а (около 500 сообщений, для 1000 правил) через reference implementation. И, как оказалось, не зря. Получившиеся тестовые примеры выявили баг на edge-case'е, когда в шаблоне email'a две звездочки отделены друг от друга одним символом. Его было трудно найти но легко исправить [28].
Бенчмарк организаторов запускает тестируемые модули через модуль vm Node'a [29], вместо простого require [30]. Что, как выяснилось [31], имеет неожиданные эффекты на производительности. Организаторы решили пересмотреть результаты.
Также я был удивлен относительно малой размерностью входных данных, которые были использованы для оценки решений. Финальную версию я гонял на 500000 сообщений и 1000 правил, и ожидал ещё больших размеров в бенчмарке организаторов. На таком датасете и с запуском через require тройка официальных лидеров показывает следующие результаты:
Для желающих, я выложил свое решение вместе с бенчмарками [32]. Предлагаю в будущем, во избежание подобных недоразумений, усреднять результат по разным размерностям входных данных, или организовывать соревнования в разных 'весовых категориях'.
Автор: tymofey
Источник [33]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/108730
Ссылки в тексте:
[1] конкурс по программированию почтовых фильтров на js: http://habrahabr.ru/company/hola/blog/270847/
[2] его результаты: http://habrahabr.ru/company/hola/blog/274697/
[3] самое короткое решение: https://github.com/hola/challenge_mail_filter/blob/master/submissions/Nadav%20Ivgi/filter.js
[4] соединить: https://en.wikipedia.org/wiki/Concatenation
[5] детерминированный конечный автомат (DFA): https://en.wikipedia.org/wiki/Deterministic_finite_automaton
[6] RegExp: https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/RegExp
[7] первая версия: https://github.com/yuri-kilochek/hola_mail_filter/commit/428d619e198abc40cee34104bb13d7c0bba64e9d
[8] генератор исходных данных и сравнение результатов с reference implementation: https://github.com/yuri-kilochek/hola_mail_filter/commit/2296e0ac5a81d7e52523e00e305be740775416af
[9] обнаружился баг, и я временно исключил её из алгоритма: https://github.com/yuri-kilochek/hola_mail_filter/commit/9099f510b52dc182d9b35b932052c49444c952de
[10] ввел задержку между запросами: https://github.com/yuri-kilochek/hola_mail_filter/commit/488c252dc3b3f13c3f9a043d22ce4d7324535e4d
[11] компиляции DFA в огромный switch на js: https://github.com/yuri-kilochek/hola_mail_filter/commit/13ba87072dfdef0c120ff69acf2e9ddc9e0b64ba
[12] lex: https://en.wikipedia.org/wiki/Lex_(software)
[13] Enron Email Dataset: https://www.cs.cmu.edu/~./enron/
[14] Пара скриптов на Python: https://github.com/yuri-kilochek/hola_mail_filter/commit/55157d5ccd5d85687c49291ec1eb104a000969c7
[15] подрвался на комбинаторной мине: https://en.wikipedia.org/wiki/Combinatorial_explosion
[16] алгоритм построения DFA: https://en.wikipedia.org/wiki/Powerset_construction
[17] premature optimization is the root of all evil: https://en.wikiquote.org/wiki/Donald_Knuth
[18] Реализация этой концепции: https://github.com/yuri-kilochek/hola_mail_filter/commit/8eb20956b157827570487cbe3953926cf700237d
[19] запоминает однажды пройденные состояния: https://github.com/hola/challenge_mail_filter/blob/master/submissions/Yuri%20Kilochek/filter.js#L206
[20] Запоминается конечное состояние автомата для каждого email'а: https://github.com/hola/challenge_mail_filter/blob/master/submissions/Yuri%20Kilochek/filter.js#L218
[21] Buffer: https://nodejs.org/api/buffer.html
[22] интерпретирую как строковый ключ: https://github.com/hola/challenge_mail_filter/blob/master/submissions/Yuri%20Kilochek/filter.js#L168
[23] .toString(): https://nodejs.org/api/buffer.html#buffer_buf_tostring_encoding_start_end
[24] реализовал это в виде пар свойств: https://github.com/yuri-kilochek/hola_mail_filter/commit/c243e48b3c3d10ffadce7719dc971d89406553b3
[25] состояния NFA можно нумеровать: https://github.com/hola/challenge_mail_filter/blob/master/submissions/Yuri%20Kilochek/filter.js#L48
[26] создать массив и буфер максимально необходимых размеров: https://github.com/hola/challenge_mail_filter/blob/master/submissions/Yuri%20Kilochek/filter.js#L150
[27] ещё один скрипт на питоне: https://github.com/yuri-kilochek/hola_mail_filter/blob/master/enron_corpus/pull_reference.py
[28] но легко исправить: https://github.com/yuri-kilochek/hola_mail_filter/commit/207b02f94131388ab4f4efde1e79dc66851a4fa4
[29] запускает тестируемые модули через модуль vm Node'a: https://github.com/hola/challenge_mail_filter/blob/master/tests/test.js#L36
[30] require: https://nodejs.org/api/modules.html#modules_module_require_id
[31] как выяснилось: http://habrahabr.ru/company/hola/blog/274697/#comment_8730195
[32] выложил свое решение вместе с бенчмарками: https://github.com/yuri-kilochek/hola_mail_filter
[33] Источник: http://habrahabr.ru/post/274935/
Нажмите здесь для печати.