Оптимизация парсера слов

Контекст:

У меня есть редактор кода/текста, который я пытаюсь оптимизировать. На данный момент узким местом программы является языковой парсер, который сканирует все ключевые слова (их больше одного, но пишутся они в целом одинаково).

На моем компьютере редактор задерживает файлы примерно на 1,000,000 строк кода. На младших компьютерах, таких как Raspberry Pi, задержка начинается намного раньше (точно не помню, но думаю около 10,000 строк кода). И хотя я никогда не видел документов размером более 1,000,000 строк кода, я уверен, что они там есть, и я хочу, чтобы моя программа могла их редактировать.

Вопрос:

Это приводит меня к вопросу: какой самый быстрый способ найти список слов в большой динамической строке?

Вот некоторая информация, которая может повлиять на разработку алгоритма:

  1. ключевые слова
  2. уточняющие символы, которые могут быть частью ключевого слова (я называю их квалификаторами)
  3. большая строка

Решение узкого места:

Это (примерно) метод, который я сейчас использую для разбора строк:

// this is just an example, not an excerpt
// I haven't compiled this, I'm just writing it to
// illustrate how I'm currently parsing strings

struct tokens * scantokens (char * string, char ** tokens, int tcount){

    int result = 0;
    struct tokens * tks = tokens_init ();

    for (int i = 0; string[i]; i++){

        // qualifiers for C are: a-z, A-Z, 0-9, and underscore
        // if it isn't a qualifier, skip it

        while (isnotqualifier (string[i])) i++;

        for (int j = 0; j < tcount; j++){

            // returns 0 for no match
            // returns the length of the keyword if they match
            result = string_compare (&string[i], tokens[j]);

            if (result > 0){ // if the string matches
                token_push (tks, i, i + result); // add the token
                // token_push (data_struct, where_it_begins, where_it_ends)
                break;
            }
        }

        if (result > 0){
            i += result;
        } else {
            // skip to the next non-qualifier
            // then skip to the beginning of the next qualifier

            /* ie, go from:
                'some_id + sizeof (int)'
                 ^

            to here:
                'some_id + sizeof (int)'
                           ^
            */
        }
    }

    if (!tks->len){
        free (tks);
        return 0;
    } else return tks;
}

Возможные решения:


Контекстные решения:

Я рассматриваю следующее:

  • Отсканируйте большую строку один раз и добавьте функцию для оценки/настройки маркеров токенов каждый раз, когда пользователь вводит данные (вместо повторного сканирования всего документа снова и снова). Я ожидаю, что это устранит узкое место, потому что синтаксический анализ требует намного меньше. Но это не исправляет программу полностью, потому что начальное сканирование может занять очень много времени.

  • Оптимизировать алгоритм сканирования токенов (см. ниже)

Я также рассмотрел, но отклонил следующие оптимизации:

  • Сканирование кода, который только на экране. Хотя это устранило бы узкое место, это ограничило бы возможность поиска определяемых пользователем токенов (т. е. имен переменных, имен функций, макросов), которые появляются раньше, чем начало экрана.
  • Переключение текста в связанный список (по узлу в строке), а не в монолитный массив. Это не очень помогает узкому месту. Хотя вставки/удаления будут выполняться быстрее, потеря индексированного доступа замедляет синтаксический анализатор. Я также думаю, что монолитный массив с большей вероятностью будет закэширован, чем разбитый список.
  • Жесткое кодирование функции сканирования-токенов для каждого языка. Хотя это может быть лучшей оптимизацией для производительности, это не кажется практичным с точки зрения разработки программного обеспечения.

Архитектурное решение:

При использовании языка ассемблера более быстрый способ анализа этих строк заключается в загрузке символов в регистры и сравнении их по 4 или 8 байтам за раз. Необходимо принять во внимание некоторые дополнительные меры и меры предосторожности, такие как:

  • Поддерживает ли архитектура невыровненный доступ к памяти?
  • Все строки должны иметь размер s, где s % word-size == 0, чтобы предотвратить нарушения чтения.
  • Другие?

Но эти проблемы кажутся легко решаемыми. Единственная проблема (кроме обычных проблем, возникающих при написании на ассемблере) заключается в том, что это не столько алгоритмическое решение, сколько аппаратное.

Алгоритмическое решение:

До сих пор я думал о том, чтобы программа изменила список ключевых слов, чтобы сделать алгоритм бинарного поиска более возможным.

Один из способов изменить их для этого — изменить размеры списка ключевых слов. Вот пример этого в C:

// some keywords for the C language

auto  // keywords[0]
break // keywords[1]
case char const continue // keywords[2], keywords[3], keywords[4]
default do double
else enum extern
float for
goto
if int
long
register return
short signed sizeof static struct switch
typedef
union unsigned
void volatile
while

/* keywords[i] refers to the i-th keyword in the list
 *
 */

Переключение размеров двумерного массива сделает его таким:

    0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3 3 3
    1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2
  -----------------------------------------------------------------
1 | a b c c c c d d d e e e f f g i i l r r s s s s s s t u u v v w
2 | u r a h o o e o o l n x l o o f n o e e h i i t t w y n n o o h
3 | t e s a n n f   u s u t o r t   t n g t o g z a r i p i s i l i
4 | o a e r s t a   b e m e a   o     g i u r n e t u t e o i d a l
5 |   k       i u   l     r t           s r t e o i c c d n g   t e
6 |           n l   e     n             t n   d f c t h e   n   i
7 |           u t                       e               f   e   l
8 |           e                         r                   d   e

// note that, now, keywords[0] refers to the string "abccccdddeeefffiilrr"

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

Что еще можно сделать для улучшения этого алгоритма?

Есть ли другой подход, который можно использовать для повышения производительности?

Примечания:

Этот вопрос от SO мне не помогает. Алгоритм Бойера-Мура-Хорспула (насколько я понимаю) — это алгоритм поиска подстроки внутри строки. Поскольку я анализирую несколько строк, я думаю, что есть гораздо больше возможностей для оптимизации.


person tay10r    schedule 21.08.2013    source источник
comment
Если вы хотите сделать это быстро, вы не зацикливаетесь со сравнением строк и списком строк, а создаете конечный автомат на основе каждого символа ВСЕХ строк, который срабатывает при обнаружении ключевого слова. Утилита Lex делает это.   -  person Jiminion    schedule 21.08.2013


Ответы (4)


Aho-Corasick — очень классный алгоритм, но он не идеален для поиска совпадений по ключевым словам, поскольку совпадения ключевых слов выравниваются; у вас не может быть перекрывающихся совпадений, потому что вы соответствуете только полному идентификатору.

Для простого поиска идентификатора вам просто нужно создать trie из ваших ключевых слов (см. примечание ниже). ).

Ваш основной алгоритм в порядке: найдите начало идентификатора, а затем посмотрите, является ли это ключевым словом. Важно улучшить обе части. Если вам не нужно иметь дело с многобайтовыми символами, самый быстрый способ найти начало ключевого слова — использовать таблицу из 256 записей, по одной записи для каждого возможного символа. Есть три возможности:

  1. Символ не может появляться в идентификаторе. (Продолжить сканирование)

  2. Символ может появляться в идентификаторе, но никакое ключевое слово не начинается с символа. (пропустить идентификатор)

  3. Персонаж может начать ключевое слово. (Начните обход дерева; если обход не может быть продолжен, пропустите идентификатор. Если обход находит ключевое слово, а следующий символ не может быть в идентификаторе, пропустите остальную часть идентификатора; если он может быть в идентификаторе, попробуйте продолжить прогулка по возможности)

На самом деле шаги 2 и 3 достаточно близки друг к другу, поэтому вам не нужна специальная логика.

В приведенном выше алгоритме есть некоторая неточность, потому что во многих случаях вы найдете что-то, что выглядит как идентификатор, но синтаксически не может им быть. Наиболее распространенными случаями являются комментарии и строки в кавычках, но в большинстве языков есть и другие возможности. Например, в C вы можете иметь шестнадцатеричные числа с плавающей запятой; хотя ни одно ключевое слово C не может быть создано только из [a-f], слово, введенное пользователем, может быть:

0x1.deadbeef

С другой стороны, C++ допускает определяемые пользователем числовые суффиксы, которые вы, возможно, захотите распознать как ключевые слова, если пользователь добавит их в список:

274_myType

Помимо всего вышеперечисленного, действительно нецелесообразно анализировать миллион строк кода каждый раз, когда пользователь вводит символ в редакторе. Вам нужно разработать какой-то способ кеширования токенизации, и самый простой и распространенный из них — кеширование по входной строке. Храните входные строки в связанном списке, и для каждой входной строки также записывайте состояние токенизатора в начале строки (т. е. находитесь ли вы в многострочной строке в кавычках, многострочном комментарии или каком-то лексическое состояние). За исключением некоторых очень причудливых языков, правки не могут повлиять на структуру токенов строк, предшествующих редактированию, поэтому для любого редактирования вам нужно повторно маркировать только отредактированную строку и любые последующие строки, состояние токенизатора которых изменилось. (Остерегайтесь слишком усердно работать с многострочными строками: это может создать много визуального шума, если перевернуть весь экран, потому что пользователь вводит незавершенную кавычку.)


Примечание. Для небольшого (сотни) количества ключевых слов полная тройка на самом деле не занимает так много места, но в какой-то момент вам придется иметь дело с раздутыми ветвями. Одна очень разумная структура данных, которая может работать очень хорошо, если вы внимательно относитесь к расположению данных, — это тройной код. дерево поиска (хотя я бы назвал его троичным деревом поиска).

person rici    schedule 21.08.2013
comment
try выглядит как очень многообещающее решение, спасибо. Что касается вашего второго раздела, я планирую реализовать функцию оценки для настройки токенов (см. вопрос: ctrl + f Scan the) без повторного сканирования. Кроме того, ввод */ потенциально может повлиять на предыдущие строки, но я понимаю вашу точку зрения. - person tay10r; 21.08.2013
comment
@TaylorFlores: Как */ может повлиять на предыдущие строки? Он завершает комментарий, который был начат, возможно, в предыдущей строке, но не делает предыдущую строку вдруг комментарием или некомментарием. (Не распознавайте незавершенные комментарии или цитаты как ошибки и не делайте с ними какие-либо действия. Это также приводит к ненужному визуальному шуму; в 99,99% случаев пользователь вот-вот закроет их. Стратегия, которую я использовал, заключается в том, чтобы избегать перекрашивания после строк, даже если состояние lex изменится до тех пор, пока пользователь не перестанет печатать на некоторое время, в надежде, что это не понадобится.) - person rici; 21.08.2013
comment
совпадения ключевых слов выравниваются; у вас не может быть перекрывающихся совпадений, потому что вы соответствуете только полному идентификатору - вопрос включал упоминание о поиске слов английского языка, которые, безусловно, могут перекрываться. - person Jim Balter; 21.08.2013
comment
@JimBalter: они не могут перекрываться, если они должны быть полными словами; в этом смысле они похожи на ключевые слова. Я признаю, что интерпретировал ОП, но я думаю, что моя интерпретация основана на разумных доказательствах. Можно утверждать, что на вопросы следует отвечать буквально правильным ответом, даже если это явно противоречит фактическим требованиям спрашивающего; лично я предпочитаю не давать (или, в некоторых случаях, давать оба ответа), но это только я. (Вы ожидаете, что английское слово colorizer раскрасит bead в f0bead042? — ​​ответ не требуется.) - person rici; 21.08.2013
comment
Вы слишком преувеличиваете... Я просто указал на нераскрытый случай; Я не говорил, что вы не должны отвечать на вопрос или что ваш ответ не является хорошим. - person Jim Balter; 21.08.2013
comment
@rici Я забыл упомянуть, что решение trie отличное. Вот обзор кода в codereview, если вам интересно посмотреть на производительность. В некоторых случаях, в зависимости от того, сколько слов в дереве, он анализирует более чем в два раза быстрее, чем я использовал раньше. - person tay10r; 19.09.2013
comment
@TaylorFlores: я ответил на ваш обзор кода с небольшим предложением, которое, я думаю, немного улучшит время выполнения, но я рад, что идея сработала хорошо. Единственная проблема с trie - это потребление памяти, но есть некоторые методы сжатия, которые не стоят так дорого. Один простой — использовать индексы в векторе узлов вместо указателей узлов; если у вас не слишком много ключевых слов, индексы будут помещаться в uint16_t, который занимает 25% пространства указателя (в 64-битной архитектуре). - person rici; 19.09.2013

Превзойти этот код будет сложно.

Предположим, что ваши ключевые слова — «a», «ax» и «foo».

Возьмите отсортированный список ключевых слов и загрузите его в программу, которая выводит такой код:

switch(pc[0]){
  break; case 'a':{
    if (0){
    } else if (strcmp(pc, "a")==0 && !alphanum(pc[1])){
      // push "a"
      pc += 1;
    } else if (strcmp(pc, "ax")==0 && !alphanum(pc[2])){
      // push "ax"
      pc += 2;
    }
  }
  break; case 'f':{
    if (0){
    } else if (strcmp(pc, "foo")==0 && !alphanum(pc[3])){
      // push "foo"
      pc += 3;
    }
    // etc. etc.
  }
  // etc. etc.
}

Затем, если вы не видите ключевое слово, просто увеличьте pc и повторите попытку. Дело в том, что отправляя первый символ, вы быстро попадаете в подмножество ключевых слов, начинающихся с этого символа. Возможно, вы даже захотите перейти на два уровня диспетчеризации.

Конечно, как всегда, возьмите несколько образцов стека, чтобы увидеть, на что используется время. В любом случае, если у вас есть классы по структуре данных, вы обнаружите, что это отнимает большую часть вашего времени, поэтому сведите это к минимуму (бросьте религию на ветер :)

person Mike Dunlavey    schedule 23.08.2013
comment
Я понимаю что ты имеешь ввиду. Я сейчас работаю над методом с хешами, который еще не опубликовал, и он тоже может быть довольно эффективным. - person tay10r; 25.08.2013
comment
@Taylor: Хэш-кодирование будет забавным упражнением, но с точки зрения циклов инструкций на входной символ код здесь будет трудно превзойти, если у вас нет миллионов ключевых слов. К тому времени, когда вы сгенерируете хэш-код входного слова, вы уже потратили больше циклов. Где хэш-кодирование выигрывает со строками, так это в том случае, если они хранятся на медленных носителях, таких как база данных. - person Mike Dunlavey; 25.08.2013
comment
после использования trie (написание которого было забавным) я фактически остановился на этом подходе. Хотя написание заняло некоторое время (на данный момент 1000 строк), это намного быстрее, чем любой из последних подходов, которые я использовал (включая trie). Спасибо еще раз! - person tay10r; 14.10.2013
comment
@Тейлор: Отлично! И теперь у вас есть совершенно новый навык, которому не учат в школе — частичная оценка — как написать программу, чтобы написать свою программу! - person Mike Dunlavey; 14.10.2013

Самый быстрый способ сделать это — использовать конечный автомат, построенный на множестве слов. Используйте Lex для создания FSM.

person Jiminion    schedule 21.08.2013
comment
Верно, но там пропущено множество деталей, и есть много способов сделать это, которые не будут самыми быстрыми. Эти проблемы уже были решены известными алгоритмами. Редактировать: lex — хорошее предложение, но flex предпочтительнее. - person Jim Balter; 21.08.2013
comment
Я думаю, что единственная проблема — это динамическая строка (например, ее редактируют?). В этом случае обратите внимание на области редактирования и сохраняйте старый поток токенов до тех пор, пока вы не окажетесь на некотором расстоянии от областей изменения, и повторите токенизацию. - person Jiminion; 21.08.2013

Лучшим алгоритмом для этой задачи, вероятно, является алгоритм Ахо-Корасика. Уже существуют реализации C, например,

http://sourceforge.net/projects/multifast/
person Jim Balter    schedule 21.08.2013
comment
спасибо за ссылку. Я все еще читаю об этом. Похоже, я не могу динамически добавлять ключевые слова в список, что является своего рода ничьей, потому что тогда я не могу добавлять определенные пользователем ключевые слова (см. вопрос: ctrl+f user-defined tokens). Это правда? - person tay10r; 21.08.2013