如何让你的正则表达式拥有更好的性能

如何让你的正则表达式拥有更好的性能

首先呢, 这里要写的不是如何编写高效的正则表达式, 而是正则表达式引擎内部使用的一些编译优化小技巧, 以及探讨这些技巧的适用范围.

这里关注的是通用NFA引擎, 也就是大家平时在各种语言里最常调用的正则表达式库使用的算法. 当然啦, 还有另外一种DFA引擎比如RE2, 由于DFA的限制, 这类引擎非常难甚至不可能实现大部分的拓展(如环视和向后引用).

文章里我将写一些通用的正则表达式引擎技术以及技巧, 在了解了引擎之后相信大家能对正则表达式由更深入的了解同时也能更好地写出更高效的正则表达式. 文章里写的大部分技术来自我的正则表达式引擎项目(各个库的做法会有差异, 不过原则是相通的, 如果对我的正则表达式引擎实现有兴趣请看文章结尾由介绍).


要讨论正则表达式引擎, 我们需要一个最基本引擎模型, 为了追求更好的性能, 这里使用的非递归的虚拟机模型, 执行的是由正则表达式编译成的字节码. 一个最基本的正则表达式虚拟机需要以下几种指令:

\begin{align} &\texttt{MATCH}, \\ &\texttt{SPLIT}, \\ &\texttt{JMP}, \\ &\texttt{ACCEPT} \end{align}

例如对于正则表达式 \texttt{(a|b)*abb} 将生成虚拟机指令:

\begin{align} &\texttt{0 split 1, 6}\\ &\texttt{1 split 2, 4}\\ &\texttt{2 match a}\\ &\texttt{3 jmp 5}\\ &\texttt{4 match b}\\ &\texttt{5 split 1, 6}\\ &\texttt{6 match a}\\ &\texttt{7 match b}\\ &\texttt{8 match b}\\ &\texttt{9 accept} \end{align}

虚拟机的实现代码如下:

enum BYTE_CODE
{
	MATCH,
	SPLIT,
	JMP,
	ACCEPT,
};

bool match_impl(std::string str, std::vector<ptrdiff_t> byte_code, 
	size_t& begin, size_t& end)
{
	struct state
	{
		size_t IP;
		size_t index;
	};

	std::vector<state> state_stack;
	size_t off = 0;

	while (off < str.length())
	{
		state_stack.clear();
		state_stack.push_back({ 0, off++ });

	fail_loop:;
		while (!state_stack.empty())
		{
			auto& state = state_stack.back();
			auto IP = state .IP;
			auto index = state.index;
			state_stack.pop_back();

		next_loop:;
			switch (byte_code[IP])
			{
			case BYTE_CODE::MATCH:
				if (index < str.length() && (str[index] == byte_code[IP + 1]))
				{
					index++;
					IP += 2;
					goto next_loop;
				}
				goto fail_loop;
			case BYTE_CODE::SPLIT:
				state_stack.push_back({ static_cast<size_t>(IP + byte_code[IP + 2]), index });
				IP += byte_code[IP + 1];
				goto next_loop;
			case BYTE_CODE::JMP:
				IP += byte_code[IP + 1];
				goto next_loop;
			case BYTE_CODE::ACCEPT:
				begin=off; end=index;
				return true;
			default:
				return false;
			}
		}
	}
	return false;
}

这里的虚拟机结构和之前的文章正则表达式与AOT编译里一致.

关于词法分析, 语法分析和指令生成等的细节这里不做更多讨论. 在有了基本的模型之后我们便可以开始讨论如何优化引擎了.



Direct threading

首先是指令分派的 \texttt{while-switch} 循环, 每执行一条指令需要执行一次 \texttt{switch} , 可以使用GCC的拓展Labels as Values将switch改写为Direct threading分派指令, 减小多余的跳转带来的开销:

static const void *next_instr[] = { 
	&&byte_code_match,
	&&byte_code_split,
	&&byte_code_jmp,
	&&byte_code_accept,
}

switch (byte_code[IP])  //for initial instruction
{

case BYTE_CODE::MATCH:
{
byte_code_match:
	//code...
}
case BYTE_CODE::SPLIT:
{
byte_code_split:
	//code...
	goto *(next_instr[byte_code[IP]]);
}
case BYTE_CODE::JMP:
{
byte_code_jmp:
	goto *(next_instr[byte_code[byte_code[IP + 1]]]);
}
case BYTE_CODE::ACCEPT:
{
byte_code_accept:
	//code...
	return true;
}

}

更进一步, 可以使用Context Threading with Tiny inlining, 将解释器改写成半JIT形式, 不过使用这类技术需要动态生成native code, 这里不做更多讨论, 有兴趣可以阅读paper:

Context Threading: A flexible and efficient dispatch technique for virtual machine interpreters [.PDF]



Memory pool

在虚拟机中我们使用了一个 \texttt{vector} 来存储每一个状态, 但是 \texttt{vector} 在这里的内存效率不高, 主要原因是不需要随机访问, 只需要访问最顶端的头部元素, 而 \texttt{vector} 随着状态增长发生的内存增长分配和搬移会带来显著的花销. 这个时候改用双向链表会比较合适, 配合内存池进行状态分配以获取更好的内存效率.

实现的思想是保存链表头部指针

  • 访问头部时直接返回该指针指向的节点中保存的状态
  • 添加新状态时检查头部指针节点的后继节点, 若为空则从内存池分配新节点, 并将头部指针指向该节点.
  • 释放状态时将头部指针移动至其指向节点的前趋节点, 而不是回收该节点的内存.

内存池的实现策略可以是一个单链表, 每个节点内包含一大块内存, 每次申请从中取出一个地址, 用尽后分配新的节点, 在完成匹配后由内存池统一回收内存. 在内存布局上新的状态储存结构更接近块状链表.



Branch stack

在正则表达式循环 \texttt{+, *, ?} 中若是出现空匹配将会造成死循环, 避免该情况的方案是在每个状态内设置一个独立的栈(使用栈是为了应对嵌套的循环, 同时这个栈是相当有用的结构, 后续将继续沿用其以支持环视与递归匹配)记录在循环开始时的匹配位置, 在匹配结束时检查是否有变化, 如果没有变换则退出该循环避免死循环.

每个状态的结构如下:

struct state
{
	size_t IP;
	size_t index;
	state_stack stack;
};

并添加两个新指令 \texttt{push, repeat} 用于循环, 此时正则表达式\texttt{(a|b)+} 将编译成:

\begin{align} &\texttt{0 push index}\\ &\texttt{1 split 2, 4}\\ &\texttt{2 match a}\\ &\texttt{3 jmp 5}\\ &\texttt{4 match b}\\ &\texttt{5 repeat 1}\\ &\texttt{6 accept} \end{align}

新增加的指令对应的虚拟机代码如下:

case BYTE_CODE::PUSH_INDEX:
	state.stack.push(index);
	goto next_loop;
case BYTE_CODE::REPEAT:
	if(state.stack.back() != index)
	{
		state_stack.push_back({ static_cast<size_t>(IP + 2]), index, state.stack });
		IP += byte_code[IP + 1];
		goto next_loop;
	}
	else	//exit loop
	{
		IP += 2;
		goto next_loop;
	}

这个时候内存效率问题出现了, 如果我们使用数组或 \texttt{vector} 来实现 \texttt{state_stack} 的话在每次执行 \texttt{split} 时需要将整个栈复制一整遍, 这是不忍直视的效率. 这个时候我们使用惰性求值的策略:

将栈实现为树形结构Branch stack, 即拥有多条分支的栈结构, 将树中.

  • 在每次复制状态时仅仅增加栈顶元素的引用计数.
  • \texttt{push} 时增长节点.
  • \texttt{pop} 时检查该节点的引用计数, 若为1则回收节点, 若大于1则减小引用计数, 增加后继节点的引用计数并且栈顶指针向后移动.
  • 修改节点数据时检查引用计数, 若为1则直接在原地修改, 否则分叉该节点, 之后在分叉后的节点上修改数据.

这里的分支栈结构比较接近functional programming的immutable data structures的实现.

在拥有栈以后可以用于计数循环的实现, 比如 \texttt{(a|b){3}} 可以在栈上面记录当前的循环次数.

注: 关于避免空循环还有另外一种做法不需要栈(在NFA生成阶段处理掉了), 如果有兴趣的小伙伴请告诉我, 我下次写写.



Loop instruction

在正则表达式中会经常出现单个字符的循环, 比如 \texttt{\d}* , 对于单个字符, 每一次循环都需要执行一次 \texttt{match} 和一次 \texttt{repeat} , 在匹配失败后还需要Backtracking, 将造成极大的性能损失, 这个时候可以引入一个新的指令 \texttt{loop} 用于执行单个字符的循环.

\texttt{loop} 的实现非常简单, 只需要在 \texttt{match} 上套上一层 \texttt{while} 循环即可. 虽然实现简单但是带来的提升却是巨大的.



Memoization

\texttt{loop} 指令的引入还可以非常容易地实现记忆化, 由于 \texttt{loop} 指令是单个字符的循环, 因此在进行状态记忆化时不需要记录下每一次进行匹配的位置, 只需要记录开始循环和结束循环的位置即可.

记忆化在考虑了memory footprint的平衡后, 我个人的建议是仅仅对 \texttt{loop} 指令进行单次的记忆化(其余指令的记忆化往往需要记录大量的已匹配位置信息, 重入时亦需要大量的检查, 往往得不偿失).

\texttt{loop} 指令的记忆化能有效地优化诸如 \texttt{a*a*a*b} 这样的正则表达式.

另外在递归匹配中也可以使用记忆化来优化同一位置的重入.



One character Lookahead

对于循环来说, 每一次循环都需要进行一次状态的保存, 在进行了以上的内存优化后仍然是一个不可忽视的开销, 在循环中可以使用单个字符的向前看来减少这种额外的开销.

举例如下:

对于正则表达式 \texttt{\d*0\d*}匹配中间存在一个字符0的字符串, 在匹配字符串1230321时, 第一个循环 \texttt{\d*} 将在每一个字符串位置进行一次状态保存, 实际上只有在匹配到123这个位置的时候才需要真正的进行状态保存, 其余位置均不需要, 因为仅有123的后继字符0能匹配.

对于循环, 我们可以通过静态分析收集循环之后的可能匹配的字符, 并在每一次循环结束时进行一次预匹配, 若是失败则不保存该状态直接进行下一轮循环.

另外我们可以通过更为精细的静态分析配合 \texttt{loop} 指令实现更高的效率, 例如匹配一个合法的Gmail邮箱的正则表达式 \texttt{[\w.]+@gmail.com} 对于循环\texttt{[\w.]+}我们使用 \texttt{loop} 的同时可以发现该处的 \texttt{loop} 其实不需要进行预查, 因为\texttt{[\w.]}\texttt{@} 并不相交, 也就是说 \texttt{[\w.]} 匹配成功则 \texttt{@} 匹配必然不成功, 那么只需要进行简单的 \texttt{while} 循环即可, 直到匹配失败后退出循环进行后续状态的匹配.



Repetition classification

对于一般的循环, 我们可以按照其属性分类

  • 计数循环与非计数循环.
  • 空循环与非空循环.

其中注意到非计数循环与非空循环并不需要在状态的栈上保留信息(非空循环不会陷入死循环), 因此也不需要在循环前执行 \texttt{push} 指令.

计数循环与非计数循环可以在语法分析阶段加以区分, 而空循环与非空循环稍微复杂一点, 需要对正则表达式做静态分析, 在中间阶段生成NFA后从循环的节点开始进行深度优先搜索(DFS)若存在一条路径不匹配任何字符则该循环为空循环, 若不存在这样的路径则意味着该循环内部至少需要匹配一个字符, 即非空循环.



Loop unrolling

对于循环展开相信大家都不会陌生, 对于计数循环我们可以对其做循环展开, 例如 \texttt{a{4}} 可展开为 \texttt{aaaa} , 展开后不需要循环指令和压栈一个计数.

更进一步地, 循环展开可以配合静态分析进行更多的优化.

其一是可以做字符合并, 为了效率我们可以引入一个新的指令 \texttt{match stirng} , 将 \texttt{match} 拓展为对字符串的匹配, 编译器可以对字符串的比较做更多的优化, 更加地, 例如 \texttt{aab{4}} 可展开为 \texttt{aabbbb} 在有字符串匹配指令下用一条指令 \texttt{match aabbbb} 即可完成匹配.

其二是可以优化掉循环中的捕获组, 这里的优化同样需要进行一些静态分析, 在循环体中有引用的捕获组不可以被删除. 例如 \texttt{((\d)\2){3}} 可展开为 \texttt{(\d)\2(\d)\2((\d)\2)} 注意捕获组1在前两个循环中被删除掉了, 因为该捕获组会被最后一个循环体内的内容覆盖, 因此没必要保存下来.



Merge branches

对于分支 \texttt{|} 来说, 在遇到单个字符求或的情况下是可以进行合并的, 例如 \texttt{1|2|3|4|5|6|7|8|9|0} 将可以合并成 \texttt{\d} .

该优化的实现并不复杂, 只需要检查每一条分支是否为单字符即可, 若是单字符则进行合并.



Conditional branches

对于\texttt{\d|[a-z]} 生成的指令如下:

\begin{align} &\texttt{0 split 1, 3}\\ &\texttt{1 match \d}\\ &\texttt{2 jmp 4}\\ &\texttt{3 match [a-z]}\\ &\texttt{4 accept} \end{align}

在执行第0号指令 \texttt{split 1, 3} 时对于分支1来说接下来立即匹配的字符是 \texttt{\d} , 这时可以将匹配提前, 在进行 \texttt{split} 时进行匹配, 若匹配成功则进行 \texttt{split} 否则直接跳转到分支3, 这里的预匹配 \texttt{\d} 便是向前看符号(lookahead).

使用向前看符号可以在第一个字符匹配失败时减少一次 \texttt{split} 操作的内存开销.

添加指令 \texttt{if(\d) 1, 3} 实现条件分支.

注意在有了 \texttt{if} 匹配成功的信息之后 \texttt{match \d} 不需要重复匹配, 使用指令 \texttt{shift} 直接向后移动一个字符. 完整指令如下:

\begin{align} &\texttt{0 if(\d) 1, 3}\\ &\texttt{1 shift}\\ &\texttt{2 jmp 4}\\ &\texttt{3 match [a-z]}\\ &\texttt{4 accept} \end{align}

值得注意的是: 这里的两条分支的第一个匹配字符是不相交的(即不存在字符对于 \texttt{\d}\texttt{[a-z]} 均能匹配), 故可以直接确定分支. 若是相交的话使用指令 \texttt{split if(\d) 1, 3} , 若 \texttt{\d} 匹配则执行 \texttt{split 1, 3} 否则直接跳转到地址3.

例如 \texttt{\d|12} :

\begin{align} &\texttt{0 if(\d) 1, 3}\\ &\texttt{1 shift}\\ &\texttt{2 jmp 5}\\ &\texttt{3 match 1}\\ &\texttt{4 match 2}\\ &\texttt{5 accept} \end{align}



Extract the common factor

另外一类的关于分支的优化是提取公因式, 例如对于匹配数字0-255正则表达式

\texttt{25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[0-9]{1,2}}

其中分支 \texttt{25[0-5]|2[0-4][0-9]} 将可以进行公因式提取变成 \texttt{2(?:5[0-5]|[0-4][0-9])} 注意提取了公因式 \texttt{2} .

提取公因式的优化原理是推迟分支, 减少重复匹配, 若是匹配失败能更早的Backtracking.

该优化的实现亦并不复杂, 只需要检查每一条分支的前缀即可, 若是相同则进行合并.



Capture group classification

对于捕获组来说, 可以依据是否在分支内分成两类, 对于不在分支上的捕获组, 我们可以直接使用一个全局数组来保存其捕获结果, 因为其与路径的选取无关, 因此无需保存在状态内. 对于在分支上的捕获组则需要在状态内保存捕获数据. 其中对数组的访问的内存效率是要高于访问状态的, 因此这类优化可以提高内存效率.

该优化的实现并不复杂, 只需要生成NFA后检查捕获组是否在分支内即可. 另外若支持递归匹配还需要考虑该捕获组是否在某个递归模式内, 因为递归模式的分支是隐含在状态内的栈上的.

另外对于在递归模式内的捕获组需要做额外的分析, 例如 \texttt{^((.)((?1)|)\2)\$} , 其中在递归中捕获组3不需要记录, 因为其在递归中没有被引用, 在递归结束后递归体内的所有捕获组将会被清空.



Capture group analysis

正则表达式里的路径分析的作用类似于常量折叠, 作用与条件表达式上, 对于向后引用也有作用. 原理是检查当前路径下所有必定捕获的捕获组用以确定条件.

举例来说表达式 \texttt{\1(.)} 必定失败, 因为捕获组1在向后引用时未被捕获, 对于这种情况可在路径的字节码最后添加上一个 \texttt{halt} 指令配合下面的Halt path elimination优化使用.

使用全局数组保存捕获结果依赖于此优化, 否则在Backtracking后数组中会留下前一状态的捕获结果, 若正好当前状态在完成捕获前引用了该捕获将会导致意料之外的结果.

另外的一个例子是 \texttt{(.)(?(1)a|b)} 将会被优化成 \texttt{(.)a} 因为在进行条件判断时捕获组1已经完成匹配了, 因此该条件表达式恒真.



Subroutine inline

在正则表达式中, 对于调用Subroutine可以使用类似于C语言中的inline优化方法把被调用的模式inline到调用处, 优点是消除了一次 \texttt{call}\texttt{return} 同时可以结合上下文环境做例如lookahead分析, Capture group analysis等的优化, 同时也利于Memorization.

举例如下:

\texttt{(a+).(?1)}

其中 \texttt{(?1)} 调用了模式 \texttt{a+} , 在inline后表达式为 \texttt{(a+).a+} .

inline是相当常见的优化技术了, 这里不做赘述.



Recursive unfold

对递归函数就行展开类似于Subroutine inline, 将最底层的递归调用展开, 好处也是于inline类似的. 值得注意的是递归的展开条件更严格, 调用递归模式在自身内只出现一次时进行展开, 否则展开的字节码将呈指数级暴涨.

举例如下, 匹配闭合的大括号:

\texttt{\{((?R)|)\}}

其中 \texttt{(?R)} 调用了整个模式自身, 在展开一次后表达式为 \texttt{\{(\{(?:(?R)|)\}|)\}}.

注意此处展开的捕获组将会被消去(由于其在递归模式中).



Halt path elimination

字节码中有一种路径是永远不会成功匹配的, 这个时候可以将其分支削去, 举例来说:

\begin{align} &\texttt{0 split 1, 3}\\ &\texttt{1 halt}\\ &\texttt{2 match a}\\ &\texttt{3 accept} \end{align}

其中 \texttt{halt} 表示无条件匹配失败, 这时候 \texttt{split 1, 3} 可以削去, 优化后的字节码如下:

\begin{align} &\texttt{0 match a}\\ &\texttt{1 accept} \end{align}



Fold control flow

这个是非常常见的指令层级的优化了, 举例来说对于表达式 \texttt{a|b|c} , 若不进行分支合并生成的字节码如下:

\begin{align} &\texttt{0 split 1, 6}\\ &\texttt{1 split 2, 4}\\ &\texttt{2 match a}\\ &\texttt{3 jmp 5}\\ &\texttt{4 match b}\\ &\texttt{5 jmp 7}\\ &\texttt{6 match c}\\ &\texttt{7 accept} \end{align}

第三行的 \texttt{jmp 5} 可以优化为 \texttt{jmp 7} . 连续跳转可以合并为单个跳转. 同样地 \texttt{split} 也可以做这样的优化, 例如 \texttt{a||c} 中的

\begin{align} &\texttt{0 split 1, 6}\\ &\texttt{1 split 2, 4}\\ &\texttt{2 match a}\\ &\texttt{3 jmp 6}\\ &\texttt{4 jmp 6}\\ &\texttt{5 match c}\\ &\texttt{6 accept} \end{align}

其中第二行 \texttt{split 2, 4} 可以优化为 \texttt{split 2, 6} .



Dead code elimination

这个也是非常常见的指令层级的优化了, 继续沿用上面的例子\texttt{a||c} , 进一步执行死代码消除后便是:

\begin{align} &\texttt{0 split 1, 2}\\ &\texttt{1 match a}\\ &\texttt{2 accept} \end{align}

这个优化实现也很简单, 这里不做赘述.



JIT/AOT

请参阅: 正则表达式与AOT编译



另外还有一些优化技术比如分支重排, 以及更细致的分支合并与跳转表我还没来得及实现与试验, 等以后有机会再向大家做更多的介绍, 另外JIT/AOT还没有真正实现好.

这篇文章主要关注的优化技术, 具体的特性实现没怎么讲, 要是大家有兴趣我再写篇文章慢慢讲, 特别是一下丧心病狂的特性应该怎么做.

以上的所有优化技术均在我的千雪(ちゆき, Chiyuki)正则表达式引擎里实现了, 感兴趣的小伙伴欢迎来看看呀(源码一共有14000多行, 用C++17写哒, 如果需要测试代码或显示字节码的代码请告诉我):

Chiyuki Regex


千雪支持的特性可以去syntax reference里看看喔(基本上环视, 捕获/引用, 命名捕获, 条件匹配, 原子组, 递归匹配都支持), 我是将她作为通用引擎设计的, 尽可能多支持一些拓展. 其中由于引擎的设计支持一些非常丧心病狂的特性, 比如无限制的环视, 你甚至可以在环视内部使用嵌套循环与递归. 而且递归也不像PCRE那样是原子性的, 这意味着你可以跨递归层进行匹配. 由于我比较笨, 在这种尤其丧心病狂的特性下想不出什么很好的test case, 又没有别的引擎来做对照, 所以不知道对不对, 有没有bug.


说了那么多优化, 我想小伙伴们肯定会好奇她的性能怎么样, 网页上的benchmark是很久以前的版本啦, 新的跑得更快一些. 现在的千雪大概跑得比非JIT的PCRE快一点(等我把JIT/AOT做出来再和你比JIT), 比Boost快两点, 将来还会更快哒. 当然啦这只是match的性能. 我没有针对search做特别的优化, 所以search在一些情况下被PCRE吊打了呀哈哈. 等我有空给大家补上新的测试数据(评论区里有3个简单的test cases对比).

她的缺点非常明显, 就是编译性能非常糟糕, 我想大家应该都能想到, 毕竟要跑那么多躺分析, 不过带来的好处也是有哒, 那就是很容易做进一步的去抽象生成native code, 也就是AOT编译啦.


我觉得她还是蛮快的, 当然只是在我的机子上, 所以不知道是不是真的呢?

最后谢谢大家观看喵~

编辑于 2018-05-14

文章被以下专栏收录