为什么使用u和i修饰符会导致一个版本的模式比另一个版本多出10倍?

时间:2016-05-07 21:41:28

标签: regex performance unicode pcre backtracking

我正在对一个字符串测试两个几乎相同的正则表达式(在regex101.com上),我注意到他们采取的步骤数量存在巨大差异。以下是两个正则表达式:

(Stake: £)(\d+(?:\.\d+)?)

(winnings: £)(\d+(?:\.\d+)?)

这是我运行它们的字符串(使用修饰符gimu):

  

开始游戏,信用:£200.00game num:1,Stake:£2.00Spinning Reels:NINE SEVEN KINGKING STAR ACEQUEEN JACK KINGtotal winnings:£0.00End Game,Credit:£198Start ...

旁注:字符串/正则表达式不是我的,我只是从elsewhere on SE中取出它们,看到这种情况发生了。它与这个问题无关,但我认为它需要归属。

(注意:Regex101似乎得到了PHP的PCRE正则表达式的支持。“优化关闭”设置可能是PCRE_NO_START_OPTIMIZE | PCRE_NO_AUTO_POSSESS。感谢Lucas Trzesniewski解决这个问题并写了一些C# code来测试它。)

优化:第一个采用 304 步骤进行匹配,而第二个采取 21 步骤。这是我想知道的巨大差异。

关闭优化:第一个采用 333 步骤进行匹配,而第二个采取 317 步骤。这表明第一种模式未能优化,而不是第二种模式。

有趣的是,没有u修饰符,第一个模式(优化开启)只需要40步。 (但它不会改变其他任何东西的表现,也不会改变正则表达式最终匹配​​的东西。)

我想知道regex101的优化引擎(PCRE)导致步数差异的原因。我特别在寻找启用u nicode时较长的正则表达式减少步数的原因。

有没有理由发生这种情况,或者这是引擎中的错误?

2 个答案:

答案 0 :(得分:10)

Regex101使用PCRE库(默认情况下),因此其调试器与PCRE的行为有关。 PCRE库通过其PCRE_AUTO_CALLOUT标志支持自动标注选项,该标志在匹配的每一步调用回调函数。我99.99%确定regex101的调试器是如何工作的。每个callout调用都在调试器中注册为 step

使用regex101进行测试

现在,看看所涉及的不同步骤,首先是没有u选项:

Without u

注意文本光标如何从Start Game部分直接跳到Stake部分。

添加u时会发生什么?

With u

请注意,跳转不再发生,是其他步骤的来源。

u选项有什么作用?它设置了PCRE_UTF8 | PCRE_UCP - 是的,这两个都是一次性的,这个PCRE_UCP标志在这里很重要。

这是pcreunicode docs所说的:

  

不区分大小写的匹配仅适用于值小于128的字符,除非PCRE是使用Unicode属性支持构建的。一些Unicode字符(例如希腊语sigma)具有两个以上与大小写等效的代码点。直到并包括PCRE版本8.31,仅支持一对一的案例映射,但后来的版本(具有Unicode属性支持)确实将所有版本的字符视为大小写,例如希腊语sigma。

由于在Unicode模式下处理不区分大小写的额外复杂性,引擎不能只跳过所有文本。要验证这是罪魁祸首,让我们使用gmu标记(即使用u但使用而不使用 i)尝试此操作:

Without i

即使使用u也会应用优化,这几乎证实了这一假设。

观察

您看到的路径看起来就像使用了PCRE_NO_START_OPTIMIZE一样(这可能是regex101' s 禁用内部引擎的一部分优化以及PCRE_NO_AUTO_POSSESS与此处无关。)

有关the docs的更多信息:

  

pcre_exec()在比赛开始时使用了许多优化,以加快进程。例如,如果已知未锚定的匹配必须以特定字符开头,它会在主题中搜索该字符,如果找不到,则立即失败,而不实际运行主匹配函数。

由于某种原因(稍后会变得明显),PCRE未能将sk字母注册为必需的起始字符,并且无法使用锚定优化。在这方面,拉丁字母的所有其他字母都可以正常工作 这就是Stake需要比winnings更多步骤的原因:引擎不会跳过检查。

直接使用PCRE进行测试

这是我与PCRE 8.38一起使用的测试程序,这是本文提供的最新PCRE1版本。

#include <stdio.h>
#include <string.h>
#include <pcre.h>

static int calloutCount;

static int callout_handler(pcre_callout_block *c) {
    ++calloutCount;
    return 0;
}

static void test_run(const char* pattern, pcre* re, pcre_extra* extra) {
    int rc, startOffset;
    int ovector[3 * 3];

    pcre_callout = callout_handler;
    calloutCount = 0;
    startOffset = 0;

    const char *subject = "Start Game, Credit: £200.00game num: 1, Stake: £2.00Spinning Reels:NINE SEVEN KINGKING STAR ACEQUEEN JACK KINGtotal winnings: £0.00End Game, Credit: £198Start...";

    for (;;) {
        rc = pcre_exec(re, extra, subject, strlen(subject), startOffset, 0, ovector, sizeof(ovector) / sizeof(int));
        if (rc < 0)
            break;
        startOffset = ovector[1];
    }

    printf("%-30s %s => %i\n", pattern, extra ? "(studied)    " : "(not studied)", calloutCount);
}

static void test(const char* pattern) {
    pcre *re;
    const char *error;
    int erroffset;
    pcre_extra *extra;

    re = pcre_compile(pattern, PCRE_AUTO_CALLOUT | PCRE_CASELESS | PCRE_MULTILINE | PCRE_UTF8 | PCRE_UCP, &error, &erroffset, 0);             
    if (re == 0)
        return;

    extra = pcre_study(re, 0, &error);

    test_run(pattern, re, extra);

    if (extra)
        test_run(pattern, re, NULL);

    pcre_free_study(extra); 
    pcre_free(re);
}

int main(int argc, char **argv) {   
    printf("PCRE version: %s\n\n", pcre_version());

    test("(Stake: £)(\\d+(?:\\.\\d+)?)");
    test("(winnings: £)(\\d+(?:\\.\\d+)?)");

    return 0;
}

我得到的输出如下:

PCRE version: 8.38 2015-11-23

(Stake: £)(\d+(?:\.\d+)?)     (studied)     => 40
(Stake: £)(\d+(?:\.\d+)?)     (not studied) => 302
(winnings: £)(\d+(?:\.\d+)?)  (studied)     => 21
(winnings: £)(\d+(?:\.\d+)?)  (not studied) => 21

在这里,我们可以看到研究模式在第一种情况下有所不同,但在第二种情况下没有。

学习模式意味着以下内容:

  

研究模式有两个作用:首先,计算匹配模式所需的主题字符串长度的下限。这并不意味着有任何匹配的字符串,但它确保没有更短的字符串匹配。该值用于通过尝试匹配短于下限的字符串来避免浪费时间。您可以通过pcre_fullinfo()函数找到调用程序中的值。

     

研究模式对于没有单个固定起始字符的非锚定模式也很有用。创建可能的起始字节的位图。这加速了在主题中找到开始匹配的位置。 (在16位模式下,位图用于小于256的16位值。在32位模式下,位图用于小于256的32位值。)

从结果和文档描述中,您可以得出结论,PCRE认为S字符是锚定字符,而在无情况Unicode模式下,它需要创建位图。位图允许应用优化。

现在,这里是PCRE2版本,针对PCRE2 v10.21编译,这是本文的最新版本。结果将不足为奇,因为PCRE2 总是研究你提供它的模式,没有问题。

#include <stdio.h>
#include <string.h>

#define PCRE2_CODE_UNIT_WIDTH 8
#include <pcre2.h>

static int callout_handler(pcre2_callout_block *c, void *data) {
    ++*((int*)data);
    return 0;
}

static void test(const char* pattern) {
    pcre2_code *re;
    int error;
    PCRE2_SIZE erroffset;
    pcre2_match_context *match_context;
    pcre2_match_data *match_data;
    int rc, startOffset = 0;
    int calloutCount = 0;
    PCRE2_SIZE *ovector;

    const PCRE2_SPTR subject = (PCRE2_SPTR)"Start Game, Credit: £200.00game num: 1, Stake: £2.00Spinning Reels:NINE SEVEN KINGKING STAR ACEQUEEN JACK KINGtotal winnings: £0.00End Game, Credit: £198Start...";

    re = pcre2_compile((PCRE2_SPTR)pattern, PCRE2_ZERO_TERMINATED, PCRE2_AUTO_CALLOUT | PCRE2_CASELESS | PCRE2_MULTILINE | PCRE2_UTF | PCRE2_UCP, &error, &erroffset, 0);
    if (re == 0)
        return;

    match_context = pcre2_match_context_create(0);
    pcre2_set_callout(match_context, callout_handler, &calloutCount);

    match_data = pcre2_match_data_create_from_pattern(re, 0);
    ovector = pcre2_get_ovector_pointer(match_data);

    for (;;) {
        rc = pcre2_match(re, subject, PCRE2_ZERO_TERMINATED, startOffset, 0, match_data, match_context);
        if (rc < 0)
            break;
        startOffset = ovector[1];
    }

    printf("%-30s => %i\n", pattern, calloutCount);

    pcre2_match_context_free(match_context);
    pcre2_match_data_free(match_data);
    pcre2_code_free(re);
}

int main(int argc, char **argv) {
    char version[256];
    pcre2_config(PCRE2_CONFIG_VERSION, &version);
    printf("PCRE version: %s\n\n", version);

    test("(Stake: £)(\\d+(?:\\.\\d+)?)");
    test("(winnings: £)(\\d+(?:\\.\\d+)?)");

    return 0;
}

结果如下:

PCRE version: 10.21 2016-01-12

(Stake: £)(\d+(?:\.\d+)?)     => 40
(winnings: £)(\d+(?:\.\d+)?)  => 21

烨。使用学习时与PCRE1相同的结果。

实施细节

让我们来看看实施细节,好吗? PCRE将模式编译为操作码,我们可以使用pcretest程序阅读。

这是输入文件:

/(Stake: £)(\d+(?:\.\d+)?)/iDW8
Start Game, Credit: £200.00game num: 1, Stake: £2.00Spinning Reels:NINE SEVEN KINGKING STAR ACEQUEEN JACK KINGtotal winnings: £0.00End Game, Credit: £198Start...

/(winnings: £)(\d+(?:\.\d+)?)/iDW8
Start Game, Credit: £200.00game num: 1, Stake: £2.00Spinning Reels:NINE SEVEN KINGKING STAR ACEQUEEN JACK KINGtotal winnings: £0.00End Game, Credit: £198Start...

它的格式很简单:分隔符中的模式后跟选项和一个或多个输入字符串。

选项包括:

  • i - PCRE_CASELESS
  • D - 打开调试模式:显示操作码和模式信息
  • W - PCRE_UCP
  • 8 - PCRE_UTF8

结果是......

PCRE version 8.38 2015-11-23

/(Stake: £)(\d+(?:\.\d+)?)/iDW8
------------------------------------------------------------------
  0  55 Bra
  3  24 CBra 1
  8     clist 0053 0073 017f
 11  /i ta
 15     clist 004b 006b 212a
 18  /i e: \x{a3}
 27  24 Ket
 30  22 CBra 2
 35     prop Nd ++
 39     Brazero
 40   9 Bra
 43  /i .
 45     prop Nd ++
 49   9 Ket
 52  22 Ket
 55  55 Ket
 58     End
------------------------------------------------------------------
Capturing subpattern count = 2
Options: caseless utf ucp
No first char
Need char = ' '
Start Game, Credit: £200.00game num: 1, Stake: £2.00Spinning Reels:NINE SEVEN KINGKING STAR ACEQUEEN JACK KINGtotal winnings: £0.00End Game, Credit: £198Start...
 0: Stake: \x{a3}2.00
 1: Stake: \x{a3}
 2: 2.00

/(winnings: £)(\d+(?:\.\d+)?)/iDW8
------------------------------------------------------------------
  0  60 Bra
  3  29 CBra 1
  8  /i winning
 22     clist 0053 0073 017f
 25  /i : \x{a3}
 32  29 Ket
 35  22 CBra 2
 40     prop Nd ++
 44     Brazero
 45   9 Bra
 48  /i .
 50     prop Nd ++
 54   9 Ket
 57  22 Ket
 60  60 Ket
 63     End
------------------------------------------------------------------
Capturing subpattern count = 2
Options: caseless utf ucp
First char = 'w' (caseless)
Need char = ' '
Start Game, Credit: £200.00game num: 1, Stake: £2.00Spinning Reels:NINE SEVEN KINGKING STAR ACEQUEEN JACK KINGtotal winnings: £0.00End Game, Credit: £198Start...
 0: winnings: \x{a3}0.00
 1: winnings: \x{a3}
 2: 0.00

现在这个变得有趣了!

首先,我们看到第一个模式获得No first char,而第二个模式获得First char = 'w' (caseless)

S标识为:clist 0053 0073 017fk变为clist 004b 006b 212a。这些是这些字母的匹配代码点集。它们代表什么?

  • S:0053 = S,0073 = s,017f = ſ(拉丁文小写长S) - Yikes。
  • k:004b = K,006b = k,212a = (开尔文标志) - 双倍赞。

不应用优化,因为这两个字母的大小写折叠包括ASCII范围之外的字符。

现在你看:):

PHP的解决方案

那么,你可以用PHP做什么?你add the S modifier

  

当一个模式将被多次使用时,值得花更多时间分析它以加快匹配所需的时间。如果设置了此修改器,则执行此额外分析。目前,研究模式仅对没有单个固定起始字符的非锚定模式有用。

Regex101不支持模式学习AFAIK。

答案 1 :(得分:2)

正如Lucas Trzesniewski所指出的,这种行为只发生在a-zks中的2个字母。其中两个出现在第一种模式中,巧合。

我想知道为什么当我将正则表达式缩短为(ke: £)(\d+(?:\.\d+)?)时,我没有看到任何改进,这就是原因。

但我认为我找到了答案。我开始挖掘PCRE源代码,挑选看起来可能感兴趣的随机文件。我找到了pcre2_udc.c

  

此表只有在构建UCP支持时才需要,而在PCRE2中则需要UTF支持自动发生。

在页面的下方,我找到了这个unicode点列表。我已将相应的Unicode字符放在其十六进制值旁边,以便快速参考:

const uint32_t PRIV(ucd_caseless_sets)[] = {
    NOTACHAR,
    0x0053, S 0x0073, s 0x017f, ſ NOTACHAR,
    0x01c4, DŽ 0x01c5, Dž 0x01c6, dž NOTACHAR,
    0x01c7, LJ 0x01c8, Lj 0x01c9, lj NOTACHAR,
    0x01ca, NJ 0x01cb, Nj 0x01cc, nj NOTACHAR,
    0x01f1, DZ 0x01f2, Dz 0x01f3, dz NOTACHAR,
    0x0345, ͅ  0x0399, Ι 0x03b9, ι 0x1fbe,  ι NOTACHAR,
    0x00b5, µ 0x039c, Μ 0x03bc, μ NOTACHAR,
    0x03a3, Σ 0x03c2, ς 0x03c3, σ NOTACHAR,
    0x0392, Β 0x03b2, β 0x03d0, ϐ NOTACHAR,
    0x0398, Θ 0x03b8, θ 0x03d1, ϑ 0x03f4,  ϴ NOTACHAR,
    0x03a6, Φ 0x03c6, φ 0x03d5, ϕ NOTACHAR,
    0x03a0, Π 0x03c0, π 0x03d6, ϖ NOTACHAR,
    0x039a, Κ 0x03ba, κ 0x03f0, ϰ NOTACHAR,
    0x03a1, Ρ 0x03c1, ρ 0x03f1, ϱ NOTACHAR,
    0x0395, Ε 0x03b5, ε 0x03f5, ϵ NOTACHAR,
    0x1e60, Ṡ 0x1e61, ṡ 0x1e9b, ẛ NOTACHAR,
    0x03a9, Ω 0x03c9, ω 0x2126, Ω NOTACHAR,
    0x004b, K 0x006b, k 0x212a, K NOTACHAR,
    0x00c5, Å 0x00e5, å 0x212b, Å NOTACHAR,
}

结果,这个正则表达式:

(ſtake: £)(\d+(?:\.\d+)?)

相当于我的第一个正则表达式。

再一次,是卢卡斯通过the pcreunicode docs的链接让我走向了正确的方向:

  

不区分大小写的匹配仅适用于值小于128的字符,除非PCRE是使用Unicode属性支持构建的。一些Unicode字符(例如希腊语sigma)具有两个以上与大小写等效的代码点。直到并包括PCRE版本8.31,仅支持一对一的案例映射,但后来的版本(具有Unicode属性支持)确实将所有版本的字符视为大小写,例如希腊语sigma。

当存在任何这些字符时,引擎无法优化。

但是在pcre2_study.c内查看,我标记了(/**/)在学习期间可能发生优化的行:

      case OP_PROP:
      if (tcode[1] != PT_CLIST) return SSB_FAIL;
        {
        const uint32_t *p = PRIV(ucd_caseless_sets) + tcode[2];
/**/    while ((c = *p++) < NOTACHAR)
          {
#if defined SUPPORT_UNICODE && PCRE2_CODE_UNIT_WIDTH == 8
          if (utf)
            {
            PCRE2_UCHAR buff[6];
            (void)PRIV(ord2utf)(c, buff);
            c = buff[0];
            }
#endif
          if (c > 0xff) SET_BIT(0xff); else SET_BIT(c);
          }
        }
      try_next = FALSE;
      break;