R 中的正则表达式操作(如 grep
或 gsub
)有时由于编码转换而导致显著的性能开销。
一些 R 代码尝试通过忽略输入编码并假装在单个字节上工作(通过 useBytes=TRUE
)来缓解此问题。这消除了此类开销,但仅在特殊情况下生成正确的结果,例如 UTF-8 中的简单正则表达式。使用 R 中 gsub
和 strsplit
的当前实现,这还可能在不知不觉中引入无效字符串,这可能会导致进一步处理中出现无效结果或错误。计划对 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
,获得了额外的性能改进,这似乎仍然比编译器可以从以前的代码中优化出的内容更快。
测量
首先,让我们看看“长”数据,它应该可以表示真实的电子书文本。报告的值是执行时间的比率,新值除以旧值,因此越小越好,四舍五入到一位小数。
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 和“固定”搜索的加速适用
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 |
宽文本不太真实,但可能类似于某些生成数据的正则表达式搜索。我将它们包括在内,因为有人向我报告了此类输入的性能问题
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 倍)
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
)。