【问题标题】:Optimizing a word parser优化单词解析器
【发布时间】:2013-08-23 06:09:12
【问题描述】:

上下文:

我有一个代码/文本编辑器,而不是我想要优化的。目前,程序的瓶颈是语言解析器没有扫描所有关键字(不止一个,但它们的写法大致相同)。

在我的计算机上,编辑器延迟了 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;
}

可能的解决方案:


上下文解决方案:

我正在考虑以下几点:

  • 扫描一次大字符串,并添加一个函数以在每次用户输入时评估/调整标记标记(而不是一遍又一遍地重新扫描整个文档)。我希望这将解决瓶颈,因为涉及的解析要少得多。但是,它并不能完全修复程序,因为初始扫描可能仍然需要 非常 很长时间。

  • 优化令牌扫描算法(见下文)

我也考虑过,但拒绝了这些优化:

  • 扫描仅在屏幕上的代码。虽然这可以解决瓶颈问题,但它会限制查找比屏幕开始位置更早出现的用户定义标记(即变量名、函数名、宏)的能力。
  • 将文本转换为链表(每行一个节点),而不是整体数组。这并没有真正帮助瓶颈。尽管插入/删除会更快,但索引访问的丢失会减慢解析器的速度。我认为,与分解列表相比,整体数组更有可能被缓存。
  • 为每种语言硬编码扫描令牌函数。虽然这可能是性能的最佳优化,但从软件开发的角度来看似乎并不实用。

架构解决方案:

使用汇编语言,解析这些字符串的更快方法是将字符加载到寄存器中并一次比较它们48 字节。还有一些额外的措施和预防措施需要考虑,例如:

  • 架构是否支持未对齐的内存访问?
  • 所有字符串的大小必须为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"

这使得使用二分搜索算法(甚至是简单的蛮力算法)更加高效。但它只是每个关键字中第一个字符的单词,之后什么都不能被认为是“排序的”。这可能有助于像编程语言这样的小词集,但对于更大的词集(比如整个英语)来说,这还不够。

还有什么可以改进这个算法的吗?

还有其他方法可以提高性能吗?

注意事项:

This question 来自 SO 对我没有帮助。 Boyer-Moore-Horspool 算法(据我了解)是一种用于在字符串中查找子字符串的算法。由于我正在解析 多个 字符串,我认为还有更多的优化空间。

【问题讨论】:

  • 如果你想快速完成它,那么你不要循环使用字符串比较和字符串列表,而是基于所有字符串的每个字符构建一个有限状态机,当找到关键字。 Lex 实用程序会执行此操作。

标签: c parsing string-parsing


【解决方案1】:

最快的方法是为单词集构建一个有限状态机。使用 Lex 构建 FSM。

【讨论】:

  • 没错,但是那里缺少很多细节,而且有很多方法不会最快。大牌算法已经解决了这些问题。编辑:lex 是一个很好的建议,但 flex 更可取。
  • 我猜唯一的错误是动态字符串(就像它正在被编辑?)。在这种情况下,请注意编辑区域并保持旧令牌流向上,直到您与更改区域保持一定距离,然后重新进行令牌化。
【解决方案2】:

解决这个问题的最佳算法可能是 Aho-Corasick。已经存在 C 实现,例如,

http://sourceforge.net/projects/multifast/

【讨论】:

  • 感谢您的链接。我仍在阅读它。看起来我不能动态地将关键字添加到列表中,这是一种平局,因为我不能添加用户定义的关键字(见问题:ctrl+f user-defined tokens)。这是真的吗?
【解决方案3】:

Aho-Corasick 是一个非常酷的算法,但它并不适合关键字匹配,因为关键字匹配是对齐的;你不能有重叠的匹配,因为你只匹配一个完整的标识符。

对于基本标识符查找,您只需使用关键字构建一个trie(请参阅下面的注释)。

您的基本算法很好:找到标识符的开头,然后查看它是否是关键字。改进这两个部分很重要。除非您需要处理多字节字符,否则查找关键字开头的最快方法是使用包含 256 个条目的表,每个可能的字符对应一个条目。有三种可能:

  1. 字符不能出现在标识符中。 (继续扫描)

  2. 该字符可以出现在标识符中,但没有关键字以该字符开头。 (跳过标识符)

  3. 字符可以开始一个关键字。 (开始遍历trie;如果无法继续遍历,则跳过标识符。如果遍历找到关键字并且下一个字符不能在标识符中,则跳过标识符的其余部分;如果可以在标识符中,请尝试继续如果可能的话,步行。)

实际上,第 2 步和第 3 步已经足够接近,您不需要特殊的逻辑。

上述算法存在一些不精确性,因为在很多情况下,您会发现一些看起来像标识符但在语法上不可能的东西。最常见的情况是 cmets 和带引号的字符串,但大多数语言都有其他可能性。例如,在 C 中,您可以使用十六进制浮点数;虽然不能仅从 [a-f] 构造 C 关键字,但用户提供的单词可能是:

0x1.deadbeef

另一方面,C++ 允许用户定义数字后缀,如果用户将它们添加到列表中,您可能希望将其识别为关键字:

274_myType

除了以上所有内容之外,每次用户在编辑器中键入一个字符时解析一百万行代码确实是不切实际的。您需要开发一些缓存标记化的方法,最简单和最常见的一种是按输入行缓存。将输入行保存在一个链表中,并且每个输入行还记录行首的标记器状态(即,无论您是在多行引用字符串中;多行注释,还是其他一些特殊的词汇状态)。除了在一些非常奇怪的语言中,编辑不会影响编辑之前行的标记结构,因此对于任何编辑,您只需重新标记已编辑的行以及标记器状态已更改的任何后续行。 (当心在多行字符串的情况下工作太辛苦:它会产生大量视觉噪音来翻转整个显示,因为用户键入一个未终止的引号。)


注意:对于少量(数百)个关键字,一个完整的 trie 并不会真正占用那么多空间,但在某些时候您需要处理臃肿的分支。 ternary search tree 是一种非常合理的数据结构,如果您注意数据布局,它可以很好地执行(尽管我将其称为三元搜索树)。

【讨论】:

  • trie 看起来是一个很有前途的解决方案,谢谢。关于您的第二部分,我确实计划实施评估功能来调整令牌(请参阅问题:ctrl + f Scan the),而无需重新扫描。此外,输入 */ 可能会影响前面的行,但我明白你的意思。
  • @TaylorFlores:*/ 如何影响前面的行?它终止了可能在前一行开始的评论,但它不会使前一行突然成为评论或非评论。 (避免将未终止的 cmets 或引号检测为错误并对其进行处理。这也会导致不必要的视觉噪音;99.99% 的时间,用户即将终止它们。我使用的策略是避免在未编辑后重新着色即使 lex 状态发生变化,直到用户停止输入一段时间,也希望它不再需要。)
  • “关键字匹配是对齐的;你不能有重叠的匹配,因为你只匹配一个完整的标识符”——问题包括提到搜索英语单词,这当然可以重叠。
  • @JimBalter:它们不能重叠如果它们必须是完整的单词;从这个意义上说,它们就像关键字。我承认我在解释 OP,但我认为我的解释是基于合理的证据。可以说,即使显然与提问者的实际要求相反,也应该用字面上正确的答案来回答问题;我个人不喜欢(或者,在某些情况下,提供两个答案),但这只是我。 (您是否希望英文单词 colourizer 为 bead 着色 f0bead042?——无需回答。)
  • @TaylorFlores:我对您的代码审查提出了一个小建议,我认为这会稍微缩短执行时间,但我很高兴这个想法运作良好。 trie 的唯一问题是存储消耗,但有一些压缩技术不会花费那么多。一个简单的方法是在节点向量中使用索引而不是节点指针;如果您没有太多关键字,索引将适合 uint16_t,它占用指针空间的 25%(在 64 位架构上)。
【解决方案4】:

很难击败这段代码。

假设您的关键字是“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 并重试。 关键是,通过调度第一个字符,您可以快速进入以该字符开头的关键字子集。 您甚至可能想要进行两个级别的调度。

当然,像往常一样,获取一些堆栈样本,看看时间被用于什么。 无论如何,如果您有数据结构类,您会发现这会占用您大部分时间,因此请尽量减少(抛开宗教:)

【讨论】:

  • 我明白你的意思。我现在正在研究一种带有哈希的方法,我还没有发布,它也可能非常有效。
  • @Taylor:哈希编码将是一个有趣的练习,但就每个输入字符的指令周期而言,除非您有数百万个关键字,否则这里的代码将很难被击败。当您生成输入字的哈希码时,您已经花费了更多的周期。如果字符串存储在慢速媒体(如数据库)上,则哈希编码会胜出。
  • 在使用了 trie(写起来很有趣)之后,我实际上最终使用了这种方法。虽然花了一些时间来编写(到目前为止 1000 行),但它比我使用的任何最后一种方法(包括 trie)都快很多。再次感谢!
  • @Taylor:太好了!现在你掌握了他们在学校没有教过的全新技能——部分评估——如何编写程序来编写程序!
猜你喜欢
  • 2018-09-07
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-08-26
  • 2023-03-27
  • 2016-01-03
  • 2015-03-31
相关资源
最近更新 更多