使用正则表达式的操作加速



R 中的正则表达式操作(如 grepgsub)有时由于编码转换而导致显著的性能开销。

一些 R 代码尝试通过忽略输入编码并假装在单个字节上工作(通过 useBytes=TRUE)来缓解此问题。这消除了此类开销,但仅在特殊情况下生成正确的结果,例如 UTF-8 中的简单正则表达式。使用 R 中 gsubstrsplit 的当前实现,这还可能在不知不觉中引入无效字符串,这可能会导致进一步处理中出现无效结果或错误。计划对 R 进行更改以降低这些风险。

这篇博文介绍了在 R 开发版本 R-devel 中实施的性能改进,以减少使用 useBytes=TRUE 来提高性能的动机。

微基准测试

我基于对来自古腾堡项目的詹姆斯·乔伊斯的《尤利西斯》文本进行操作的微基准测试实施了几项优化。18% 的输入行包含一些非 ASCII 字符(通常是花式引号)。

我使用了几个任意正则表达式:“a”和“ ”(获得许多匹配项)、“XXX”(未获得匹配项)、“[aeiou]”(一个组,许多匹配项)。我还使用了一个具有非常长的输入行的变体。

我在这篇博文中使用不同的输入进行评估,我尚未调整代码。

玛丽·贝洛克·隆德斯的《房客》有 英文 版和 日文 版。英文版有 35% 的非 ASCII 输入行,日文版有 80%。

作为正则表达式,让我们对英文版使用“XXX”、“London”、“the”、“[Tt ]”,对日文版使用“XXX”、“ロンドン”、“し”、“[し。]”。让我们使用“REP”作为替换。为避免测量噪声,重复文本 100 倍,因此使用原始文本(“长”数据)的许多行。或者,在每一行上重复文本 100 倍(“宽”数据)。

输入/输出转换

R 使用的正则表达式引擎具有支持 Unicode 的模式。在此模式下,所有输入都必须转换为特定编码。

用于实现 POSIX 扩展正则表达式的 TRE 在宽字符中工作(Windows 上的 UTF-16LE 和 Unix 上的 UTF-32LE/BE)。R 从不以宽字符在内部存储字符串,因此它在将输入传递给 TRE 之前必须始终转换输入,并且在生成输出时可能必须进行一些转换“返回”UTF-8。

PCRE2 用于实现 Perl 正则表达式,在支持 Unicode(如 R 中所用)时使用 UTF-8。由于 R 中的字符串通常采用 UTF-8,并且自 R 4.2.0 以来甚至在 Windows 上也是如此,因此几乎不再需要转换。这是 PCRE2 相对于 TRE 的优势。然而,在将字符串传递给 PCRE2 之前,R 仍会检查它们是否是有效的 UTF-8。

为了减少转换开销,当所有输入均为 ASCII 时,R 会自动选择非 Unicode 模式。当一个可能很长的输入向量(例如文本行)中即使有一个元素是非 ASCII 时,就会使用 Unicode 模式,并且必须转换所有输入。对于 UTF-8,这是一个无操作,但转换为 UTF-16 或 UTF-32 需要一些工作。

这些额外的性能优化现已实现。

从 ASCII 转换为宽字符的处理方式很特别。R 知道哪些 R 字符串是 ASCII(头文件中有一个位),因此检查是常量时间,然后转换是一个简单的紧密循环。这有助于 TRE。

从宽字符转换为 UTF-8 的处理方式针对预期结果为 ASCII 的情况进行了专门设计。转换仍会检查结果是否确实为 ASCII,如果不是,则会使用完整转换重新开始。显然,这可以通过编译正则表达式两次并在需要时对某些输入元素使用非 Unicode 模式来进一步优化。这有助于使用 TRE 进行替换搜索。

事实证明,除了 R 验证输入是否为有效的 UTF-8 字符串之外,PCRE 中还进行了重复验证。PCRE 有一个标志可以在这些情况下禁用该验证,该标志现已设置,从而提高了使用 PCRE 进行搜索的性能。在具有多个匹配项的非常长的向量中,进行了许多不必要的验证,因此改进非常显著。

已修复的搜索

当模式(和替换)是固定字符串时,R 还通过正则表达式函数支持“固定”搜索。这些直接在 R 内部实现。到目前为止,它们仅针对搜索单个 ASCII 字符进行了优化。

UTF-8 编码具有一个很好的特性,即前导字节与延续字节来自不同的范围。因此,可以在另一个有效的 UTF-8 字符串中逐字节搜索有效的 UTF-8 字符串,因为不存在匹配部分(无效)子字符串的风险,并且此搜索比解码字符时更快。

此外,计算有效 UTF-8 字符串中的字符数很容易:只需计算前导字节(很容易检测到),因此这比“解码”和计算字符更快。

现在,“固定”搜索已得到扩展,以利用这些属性并加快 UTF-8 中的搜索和替换。

通过将主循环切换到 C 函数 strstr,获得了额外的性能改进,这似乎仍然比编译器可以从以前的代码中优化出的内容更快。

测量

首先,让我们看看“长”数据,它应该可以表示真实的电子书文本。报告的值是执行时间的比率,新值除以旧值,因此越小越好,四舍五入到一位小数。

长英文文本
grep
gsub
TRE PCRE2 固定 TRE PCRE2 固定
XXX 0.9 0.7 0.3 0.8 0.8 0.3
London 0.9 0.7 0.3 0.9 0.8 0.3
the 0.8 0.8 0.5 0.8 0.8 0.4
[Tt ] 0.8 0.7 NA 0.8 0.7 NA

对“XXX”和“London”(很少或从未出现的字符串)进行“固定”搜索显示为 0.3,因此它大约花费了原始时间的 30%,因此延迟加速约为 3.3 倍。

在文本为 ASCII 时转换的优化不适用于日语文本,因此 TRE 的性能没有得到很大提高,但转换的一些小简化有助于频繁匹配。PCRE2 和“固定”搜索的加速适用

长日语文本
grep
gsub
TRE PCRE2 固定 TRE PCRE2 固定
XXX 1 0.6 0.4 1.0 0.7 0.4
<U+30ED><U+30F3><U+30C9><U+30F3> 1 0.8 0.5 1.0 0.8 0.5
<U+3057> 1 0.8 0.7 0.9 0.8 0.6
[<U+3057><U+3002>] 1 0.8 NA 0.9 0.7 NA

宽文本不太真实,但可能类似于某些生成数据的正则表达式搜索。我将它们包括在内,因为有人向我报告了此类输入的性能问题

宽英文文本
grep
gsub
TRE PCRE2 固定 TRE PCRE2 固定
XXX 0.8 0.6 0.2 0.9 0.6 0.2
London 0.8 0.6 0.2 0.9 0.5 0.2
the 0.8 0.7 0.4 0.8 0.1 0.3
[Tt ] 0.6 0.6 NA 0.9 0.0 NA

PCRE2 gsub 的 0.0 是舍入的结果:执行时间不到原始时间的 3%,因此加速约为 40 倍。这当然很好地证明了微基准可以任意夸大性能变化,但造成这种情况的主要原因是 PCRE2 意外地重复验证了字符串(即使 R 知道它们有效)。

在日语文本中,TRE 没有可见的改进。PCRE2/gsub 的改进再次大幅提升(0.0 表示 30 倍)

宽日语文本
grep
gsub
TRE PCRE2 固定 TRE PCRE2 固定
XXX 1 0.5 0.4 1 0.5 0.3
<U+30ED><U+30F3><U+30C9><U+30F3> 1 0.9 0.4 1 0.7 0.4
<U+3057> 1 0.6 0.8 1 0.1 0.5
[<U+3057><U+3002>] 1 0.6 0.4 1 0.0 0.4

这些数字是在运行 Ubuntu 20.04 的 64 位 Intel 笔记本电脑上测量的。

总结和建议

以上数字表明,R 中正则表达式操作中的编码转换当前开销已减少,特别是对于 PCRE2(最近系统上的 perl=TRUE)和“固定”搜索。

PCRE2 最终几乎总是比 TRE 快,通常快几倍。至少部分原因可能是 TRE 需要的宽字符串编码转换。在新的代码中,使用 Perl 正则表达式可能更有意义,无论如何,由于支持 Unicode 属性并且 PCRE2 仍在积极维护,而 TRE 则不然。

此外,在这些优化之后,“固定”搜索仍然比 PCRE2 快,因此当性能确实至关重要并且我们在寻找固定字符串时,可以使用它们。

useBytes=TRUE 是一种提高正则表达式操作性能的危险方式,并且已经实施了这些优化以减少使用它的动机。希望现在可以移除更多此类用法(切换到默认 useBytes=FALSE)。