解析器加速



我的主要目标既不是提高解析器性能,也不是对其进行衡量。我一直在致力于优化,以减少将源引用包含到包中的运行时开销(由于空间和执行时间开销,默认情况下不执行此操作)。我添加了一个选项,用于从源引用中排除解析数据,并默认情况下为包启用了该选项,因为解析数据占源引用的运行时开销的大部分,而它们很少需要。显然,不收集解析数据还可以加快使用源引用的解析速度。在调试解析数据中的错误时,我发现即使未收集源引用,解析数据中的父项信息也会更新;修复此问题还可以提高性能。然后,我根据错误报告对解析器结构的初始化、内存分配和内存保护进行了一些修复,并解决了我在阅读代码时发现的错误,包括将 `UNPROTECT_PTR` 与 `UNPROTECT` 调用混合使用的潜在问题。我一直在分析我的更改,以验证我是否没有不必要地添加性能回归,并且此分析揭示了大型输入上解析数据后处理的病态性能开销。对于分析,我通过连接 R 基本包中的所有文件来人为地创建了一个大文件,并且在解析数据后处理中花费了超过 90% 的时间(其余所有时间包括解析在内占 10%),但此百分比取决于输入的大小,对于较小的文件,它要小得多。我修复了其主要原因(查找注释的父节点的传递)。在对 CRAN 和 BIOC 包中的真实(非连接)文件进行分析时,我发现并修复了另一个病态案例,该案例使解析数据后处理在生成大量表达式时非常慢(使用从解析数据中排除的节点更新父项信息)。在分析过程中,我还发现解析器最近(在 3.4 和 3.5 之间)有一些性能改进,对此我在解析器代码的提交日志中找不到任何合理的解释。我很好奇它们是什么,所以我决定更详细地衡量解析器性能。

示例文件

以下性能数字需要在上下文中查看,因为它们取决于为测量选择的那些文件。对于某些优化,创建输入文件以报告任意大的相对性能改进一点也不难——至少对于查找注释的父项,注释输入文件越大,优化的相对加速就越大。我选择测量 R 3.5.2 中base 包、compiler 包、tools 包、utils 包和stats 包的连接源的解析时间,以表示不同的编码样式和文件大小。我还添加了all,它是基本发行版中所有包的所有源的连接,包括之前单独测量的那些源,以表示带注释的大型手写代码(这也是我在优化过程中进行分析的同一组)。然后,我添加了 CRAN/BIOC 包中最大的文件,它们也代表了一些病理案例:rgtk2 是包RGtk2 2.20.35 版中的gtkFuncs.R(一个没有注释的大文件),phylosim 是包phylosim 3.0.2 版中的PhyloSimSource.R(一个包含大量内联注释的大文件,用于内联技术文档),rcoletum 是包RColetum 0.2.0 版中的test-GetAnswersComplexForm.R(一个包含对structure() 的调用的大型生成文件,该调用具有大量参数)。这些也是 CRAN+BIOC 包中行数最多的三个文件(来自我定期检查的子集,其中排除了几个依赖关系不容易获得的包)。

字符
base 20K 730K
stats 29K 1057K
compiler 3K 102K
tools 42K 1634K
utils 20K 729K
all 156K 5793K
phylosim 52K 1074K
rcoletum 82K 5418K
rgtk2 41K 780K

测量

每次迭代都使用以下内容运行RScript 的新实例

options(keep.parse.data = KEEP_PARSE_DATA)
system.time(
  dummy <- parse(FILENAME, keep.source= KEEP_SRCREFS)
)

我使用每个 R 版本和源文件运行了这三个设置(无源引用、无解析数据的源引用、带解析数据的源引用)各 20 次,报告平均经过时间。我还计算了 95% 自助置信区间,用于决定将结果四舍五入到多少位数,但不会直接报告。在 64 位 Ubuntu 笔记本电脑上测量。数字以秒为单位,因此越低越好。

我使用了 3.3、3.4 和 3.5 版本的最新 R tarball,然后选择了 R-devel 版本(我必须测量比此处显示的更多一些内容,以验证有趣的变化发生在何处;请注意 73746 和 73747 介于 3.4 和 3.5 之间,因此在“旧”R-devel 中,但 75177 及更高版本已经是当前 R-devel)。

带解析数据的源引用

KEEP_PARSE_DATA=TRUE, KEEP_SRCREFS=TRUE(带解析数据的源引用)对应于显式调用 parse() 以使用默认参数解析文件。

3.3.3 3.4.4 3.5.2 73746 73747 75177 75178 75754 75873 75883
all.r 20.700 20.600 25.8000 26.0000 25.7000 25.800 25.8000 25.0000 1.560 1.600
base.r 0.372 0.370 0.4400 0.4480 0.4480 0.440 0.4390 0.4600 0.103 0.102
compiler.r 0.017 0.023 0.0218 0.0189 0.0189 0.023 0.0222 0.0229 0.019 0.019
phylosim.r 2.000 1.500 2.4000 2.3900 2.4000 2.400 2.4200 2.6000 0.110 0.110
rcoletum.r 18.700 18.600 19.0000 18.6000 18.0000 19.000 19.0000 18.3000 18.300 0.770
rgtk2.r 0.293 0.308 0.1990 0.2070 0.2060 0.199 0.2000 0.1200 0.120 0.130
stats.r 0.720 0.720 0.7800 0.7990 0.7900 0.790 0.8100 0.7900 0.178 0.178
tools.r 0.720 0.702 0.6890 0.6890 0.6230 0.700 0.7000 0.7000 0.300 0.298
utils.r 0.270 0.270 0.2720 0.2680 0.2690 0.274 0.2710 0.2880 0.100 0.101

这些数字显示了 R-devel 75873 中所有示例文件(rcoletumrgtk2 除外)的速度提升。这是在为注释查找父节点时实现的速度提升,rcoletum 不受影响,因为它只有少量注释,rgtk2 则根本没有注释。查找父节点的原始算法在最坏情况下是二次的,因为它必须遍历每个注释的整个剩余解析数据。新算法在解析数据的大小上几乎是线性的,它利用了解析数据中非注释节点中已有的父级信息,以及解析器满足的解析数据中条目的某些排序属性(源代码注释中有更多详细信息)。所有大型文件都可能受益,甚至可以看到最小示例文件(compiler,约 33%)有一些改进。

这些数字还显示了 rcoletum 在 R-devel 75883 中的速度提升。这是在用解析数据中排除的节点更新父级信息时进行的一个简单优化。对于每个节点,必须遍历一系列排除的节点,直到找到一个未排除的节点。对于非常大的表达式(在这种情况下是参数列表,但也可以是大型算术表达式),排除的节点序列很长,并且排除的节点是重复的,因此搜索也会重复。优化记录了已找到的父级,并在后续搜索中重新使用它。速度提升取决于表达式树的形状,并且可能只有生成的文件会受益。其他文件的花费应该是可以忽略不计的(并且这与数字一致)。

对于 allphylosimbase,在 3.4.4 和 3.5.2 之间似乎也有一些速度变慢。这些速度变慢在 75873 和 75883 中被消除。它们可能是(但我没有验证)由解析数据后处理中使用的整数向量的访问虚拟化引起的。原则上,可以通过重写解析数据后处理的两个循环来进一步提高 75883 的速度,但尚不清楚是否值得增加代码的认知复杂性。73747 在某些情况下有所帮助,但由于 75873 和 75883 中发生了重大变化,这再次变得不再有趣。

不带解析数据的源引用

KEEP_PARSE_DATA=FALSE, KEEP_SRCREFS=TRUE(不带解析数据的源引用)对应于现在使用 R_KEEP_PKG_SOURCE=yes 安装软件包时默认执行的操作。

75177 75178 75754 75873 75883
all.r 1.2700 1.0000 0.8100 0.813 0.820
base.r 0.0720 0.0640 0.0610 0.062 0.063
compiler.r 0.0138 0.0122 0.0122 0.012 0.013
phylosim.r 0.0730 0.0600 0.0600 0.070 0.070
rcoletum.r 0.3900 0.3280 0.3470 0.347 0.400
rgtk2.r 0.1670 0.1560 0.0900 0.088 0.087
stats.r 0.1200 0.1040 0.1100 0.107 0.107
tools.r 0.2100 0.2000 0.1980 0.200 0.200
utils.r 0.0680 0.0590 0.0610 0.062 0.062

这些数字仅适用于当前的 R-devel 版本,因为在 R-devel 74761 中添加了排除解析数据选项。在之前的版本中,人们总是使用源引用获取解析数据(使用 all 时开销几乎为 25 倍)。

在 R-devel 75754 中,allrgtk2 的速度明显加快。此优化使用可伸缩列表作为解析器中的中间结构,在将源引用压缩到固定大小的新列表块之前,这些列表会保存源引用。可伸缩列表是允许恒定时间追加操作的链表,并且已在解析器中使用,但临时源引用列表使用传统的 pairlist,其追加操作为线性时间。预期此优化仅在这些列表很长时才会有所帮助,例如在包含大量(短)函数的大型文件中(这可能是它在 rgtk2 中如此有帮助的原因)或在函数体非常长的函数中。

版本 75178 中有所改进,该优化去除了对解析数据不必要的父级信息更新。当不收集解析数据时,不需要此类更新。

无源引用,无解析数据

KEEP_PARSE_DATA=FALSE, KEEP_SRCREFS=FALSE(无源引用,无解析数据)对应于现在(在 3.5 和当前 R-devel 中)在安装包时默认执行的操作。

3.3.3 3.4.4 3.5.2 73746 73747 75177 75178 75754 75873 75883
all.r 0.7310 0.7200 0.4490 0.7040 0.5500 0.5220 0.460 0.470 0.480 0.4810
base.r 0.0630 0.0632 0.0477 0.0500 0.0511 0.0570 0.049 0.051 0.052 0.0520
compiler.r 0.0090 0.0092 0.0090 0.0080 0.0082 0.0110 0.010 0.010 0.010 0.0102
phylosim.r 0.0618 0.0620 0.0482 0.0500 0.0507 0.0570 0.049 0.051 0.052 0.0520
rcoletum.r 0.5600 0.5720 0.3000 0.5310 0.3640 0.3600 0.300 0.330 0.330 0.3300
rgtk2.r 0.0849 0.0840 0.0659 0.0690 0.0700 0.0761 0.067 0.069 0.071 0.0710
stats.r 0.0980 0.0972 0.0760 0.0900 0.0900 0.0880 0.077 0.081 0.083 0.0830
tools.r 0.1570 0.1560 0.0960 0.1150 0.1200 0.1140 0.097 0.102 0.104 0.1100
utils.r 0.0640 0.0629 0.0470 0.0498 0.0501 0.0562 0.048 0.051 0.051 0.0510

在绝对数字中,即使对于大型源文件,此配置中的解析时间也很小,但它们仍然会影响包的安装时间。

与在不使用解析数据的情况下收集源引用类似,版本 75178 中有所改进(不更新解析数据的父级,因为它们未被收集)。

此外,在 3.4.4 和 3.5.2 之间,所有示例文件(可能除了 compiler)的速度都得到了提升。仔细观察可以清楚地看到,虽然解析器代码几乎没有变化;但一个例外是 75178 的移植,它避免在未收集源引用时更新父级信息,移植是因为这是一个错误修复。3.4.4 和 3.5.2 之间的大部分加速都是通过 R 代码中的其他更改间接发生的,并且似乎是许多较小的性能更改(包括减速和加速)的结果。

73747(当 R-devel 成为 R 3.5 时)中出现了一次较大的提速。在此提交中,Luke Tierney 更改了 GC 启发式方法,以便在 R 启动后的前几次堆调整中更激进地为节点(单元格)增加堆。这是在我们进行的分析之后进行的,该分析确定了字节码解释器中的字节编译代码运行速度比 AST 解释器慢的几种情况(开销不在于编译 - 代码已经编译)。事实证明,这是由于编译代码时 GC 压力(不必要地进行太多 GC 运行)造成的,而代码占用了初始堆大小的非平凡内存量。解析器,尤其是标记器,占用了相当多的内存,因此 GC 启发式方法产生影响也就不足为奇了。

73554 中出现了另一次提速(这些数字中未显示,我刚刚针对 gtk2 进行了测量),同样是由于 GC,我更新了 GC 初始向量内存大小,从而使向量堆增长得更快。考虑到当前通常的内存大小,这一更改是有意义的,而且它再次分析了编译代码加载时间过长的情况,从而导致在已经编译的字节码中出现一些变慢的情况。

总结

R 解析器性能自 3.4 版本以来已经得到改进,在 3.5 版本中已经得到改进,而当前的 R-devel 在收集带有解析数据的源引用时还有其他改进。在当前的 R-devel 中,还可以收集不带解析数据的源引用,这除了减少安装大小之外,速度也明显更快。