改进 Perl 应用程序性能
我和一位开发同事一直在开发一个主要用 Perl 编写的数据收集应用程序。该应用程序从目录中检索测量文件,解析文件,执行一些统计计算,并将结果写入数据库。我们需要提高应用程序的性能,以便它在生产中使用时能够处理相当大的负载。
本文介绍了四个性能调优步骤:识别、基准测试、重构和验证。这些步骤应用于现有应用程序以提高其性能。一个函数被识别为可能的性能问题,并建立该函数的基线基准。对该函数迭代地应用几个优化,并将性能改进与基线进行比较。
提高应用程序性能的首要任务是确定应用程序的哪些部分性能不佳。在这种情况下,我使用了两种技术来识别潜在的性能问题:代码审查和性能分析。
性能代码审查是通读代码以查找可疑操作的过程。代码审查的优点是审查者可以观察数据在应用程序中的流动。了解数据在应用程序中的流动有助于识别可以消除的任何控制循环。它还有助于识别应该使用应用程序性能分析进一步审查的代码段。我不建议将性能代码审查与其他类型的代码审查(例如用于标准合规性的代码审查)结合使用。
应用程序性能分析是监控应用程序的执行以确定花费最多时间的位置以及操作执行频率的过程。在这种情况下,我使用了名为 Benchmark::Timer 的 Perl 包。这个包提供了我用来标记代码中感兴趣部分的开始和结束的函数。这些标记的代码段中的每一个都由一个标签标识。当程序运行时,并且进入标记的部分时,会记录在该标记部分内花费的时间。
向应用程序添加性能分析部分是一种侵入式技术;它改变了代码的行为。换句话说,性能分析代码有可能掩盖或模糊性能问题。在性能调优的早期阶段,这可能不是问题,因为性能问题的严重程度将明显大于性能分析代码的性能影响。但是,随着性能问题的消除,随后的性能问题更可能难以区分。像许多事情一样,性能改进是一个迭代过程。
在我们的例子中,对代码某些部分的性能分析表明,相当多的时间花在计算从机器上收集的数据的统计信息上。我审查了与这些统计计算相关的代码,并注意到一个用于计算标准差的函数 std_dev 被频繁使用。std_dev 计算引起了我的注意,原因有二。首先,因为计算标准差需要计算均值和整个测量集的平方和的均值,所以 std_dev 的简单计算使用了两个循环,而实际上可以用一个循环完成。其次,我注意到整个数据数组都被作为值传递到堆栈上的 std_dev 函数中,而不是作为引用传递。我认为这两个项目加在一起可能表明存在值得检查的性能问题。
在识别出可以改进的函数后,我继续进行下一步,即对该函数进行基准测试。基准测试是建立用于比较的基线测量的过程。创建基准是了解修改是否真正提高了某些事物性能的唯一方法。此处呈现的所有基准都是基于时间的。幸运的是,开发了一个名为 Benchmark 的 Perl 包,专门用于生成基于时间的基准。
我将 std_dev 函数(列表 1)从应用程序中复制出来并放入一个测试脚本中。通过将函数移动到测试脚本,我可以在不影响数据收集应用程序的情况下对其进行基准测试。为了获得具有代表性的基准,我需要复制数据收集应用程序中存在的负载。在检查了数据收集应用程序处理的数据后,我确定一组介于 0 和 999,999 之间的所有数字的随机排列集合就足够了。
列表 1. std_dev 的基线实现
sub mean { my $result; foreach (@_) { $result += $_ } return $result / @_; } sub std_dev { my $mean = mean(@_); my @elem_squared; foreach (@_) { push (@elem_squared, ($_ **2)); } return sqrt( mean(@elem_squared) - ($mean ** 2)); }
为了产生可靠的基准,std_dev 函数必须重复多次。函数运行的次数越多,基准就越可靠或一致。可以使用 Perl Benchmark 包专门设置重复基准的次数。例如,运行此基准 10,000 次。或者,该包接受时间持续时间,在这种情况下,基准在分配的时间内尽可能多地重复。本文中显示的所有基准均使用 10 秒的迭代参数。计算 1,000,000 个数据元素的标准差至少 10 秒产生了结果
12 wallclock secs (10.57 usr + 0.02 sys = 10.59 CPU) @ 0.28/s (n = 3)
此信息表明基准测量花费了 12 秒运行。基准工具能够每秒执行该函数 0.28 次,或者取倒数,每次迭代 3.5 秒。基准实用程序在分配的 10 个 CPU 秒内只能执行该函数三次 (n = 3)。在本文中,结果使用每次迭代秒数 (s/iter) 来衡量。数字越低,性能越好。例如,瞬时函数调用将花费 0 秒/迭代,而非常糟糕的函数调用将花费 60 秒/迭代。既然我已经有了 std_dev 性能的基线测量,我就可以衡量重构函数的效果了。
虽然三个样本足以识别 std_dev 计算的问题,但更深入的性能分析应该有更多样本。
在建立列表 1 中所示的基准之后,我在两次迭代中改进了 std_dev 算法。第一个改进,称为 std_dev_ref,是将参数传递从“按值传递”更改为 std_dev 函数和 std_dev 调用的 mean 函数中的“按引用传递”。结果函数如列表 2 所示。理论上,这将通过避免在调用 std_dev 和随后调用 mean 之前将数据数组的全部内容复制到堆栈上来提高两个函数的性能。
列表 2. 用按引用调用替换按值调用
sub mean_ref { my $result; my $ar = shift; foreach (@$ar) { $result += $_ } return $result / scalar(@$ar); } sub std_dev_ref { my $ar = shift; my $mean = mean_ref($ar); my @elem_squared; foreach (@$ar) { push (@elem_squared, ($_ **2)); } return sqrt( mean_ref(\@elem_squared) - ($mean ** 2)); }
第二个改进,称为 std_dev_ref_sum,是完全删除 mean 函数。均值和平方和的均值组合成一个循环遍历整个数据集。列表 3 中显示的这个改进至少消除了对数据的两次迭代。表 1 包含基准时间的摘要。
列表 3. 删除 Mean 函数后
sub std_dev_ref_sum { my $ar = shift; my $elements = scalar @$ar; my $sum = 0; my $sumsq = 0; foreach (@$ar) { $sum += $_; $sumsq += ($_ **2); } return sqrt( $sumsq/$elements - (($sum/$elements) ** 2)); }
正如所希望的那样,表 1 显示了每次改进之间的增量改进。在 std_dev 和 std_dev_ref 函数之间有 20% 的改进,在 std_dev 和 std_dev_ref_sum 函数之间有 158% 的改进。这似乎证实了我对 Perl 中按引用传递比按值传递更快的预期。此外,正如预期的那样,删除对数据的两次循环提高了 std_dev_ref_sum 函数的性能。在进行这两次改进之后,该函数可以在 1.37 秒内计算出 1,000,000 个项目的标准差。虽然这比最初的版本好得多,但我仍然认为还有改进的空间。
有许多开源 Perl 包可用。希望我可以找到一个比我目前为止的最佳尝试更快的标准差计算方法。我从 CPAN 找到了并下载了一个名为 Statistics::Descriptive 的统计包。我创建了一个名为 std_dev_pm 的函数,该函数使用了 Statistics::Descriptive 包。此函数的代码如列表 4 所示。
列表 4. std_dev_pm 函数
sub std_dev_pm { my $stat = new Statistics::Descriptive::Sparse(); $stat->add_data(@_); return $stat->standard_deviation(); }
但是,使用此函数产生的结果为 6.80 秒/迭代;比基线 std_dev 函数差 48%。考虑到 Statistics::Descriptive 包使用对象接口,这并非完全出乎意料。每次计算都包括构造和析构 Statistics::Descriptive::Sparse 对象的开销。这并不是说 Statistics::Descriptive 是一个糟糕的包。它包含大量用 Perl 编写的统计计算,并且对于不必快速的计算来说易于使用。但是,对于我们的具体情况,速度更重要。
所有语言都有优点和缺点。例如,Perl 是一种很好的通用语言,但不是最适合数值计算的语言。考虑到这一点,我决定用 C 重写标准差函数,看看它是否能提高性能。
对于数据收集应用程序的情况,用 C 重写整个项目将适得其反。相当多的特定 Perl 实用程序使其成为大多数应用程序的最佳语言。重写应用程序的替代方法是仅重写那些特别需要性能改进的函数。这是通过将用 C 编写的标准差函数包装到 Perl 模块中来完成的。包装 C 函数使我们能够将程序的大部分保留在 Perl 中,但也允许我们在适当的地方混合使用 C 和 C++。
在现有的 C 或 C++ 接口上编写 Perl 包装器需要使用 XS。XS 是随 Perl 包分发的工具,并且记录在 perlxs Perl 文档中。您还需要 perlguts 文档中的一些信息。使用 XS,我创建了一个名为 OAFastStats 的 Perl 包,其中包含用 C 实现的标准差函数。然后可以直接从 Perl 调用此函数(如列表 5 所示)。为了进行比较,这个标准差函数将被调用 std_dev_OAFast。
列表 5. XS 实现
double std_dev(sv) INPUT: SV * sv CODE: double sum = 0; double sumsq = 0; double mean = 0; /* Dereference a scalar to retrieve an array value */ AV* data = (AV*)SvRV(sv); /* Determine the length of the array */ I32 arrayLen = av_len(data); if(arrayLen > 0) { for(I32 i = 0; i <= arrayLen; i++) { /* Fetch the scalar located at i from the array.*/ SV** pvalue = av_fetch(data,i,0); /* Dereference the scalar into a numeric value. */ double value = SvNV(*pvalue); /* collect the sum and the sum of squares. */ sum += value; sumsq += value * value; } mean = (sum/(arrayLen+1)); RETVAL = sqrt((sumsq/(arrayLen+1)) - (mean * mean)); } else { RETVAL = 0; } OUTPUT: RETVAL
表 2 中介绍了基线标准差函数与用 XS 包装的 C 函数之间的比较,显示出显着的加速。C 函数 (std_dev_ref_OAFast) 比基线函数 (std_dev) 快 1,175%,比最佳 Perl 实现 (std_dev_ref_sum) 快 395%。
在此过程中,我识别出一个可能性能不佳的函数。通过改进 Perl 中计算的逻辑,我能够获得一些适度的性能提升。我还尝试使用一个开源包,但发现它比我的原始函数差 48%。最后,我用 C 实现了标准差函数,并通过 XS 层将其暴露给 Perl。C 版本显示,与原始 Perl 版本相比,速度提高了 1,175%。改进总结在图 1 中。

图 1. 所有实现的比较
在大多数情况下,我看到的 Perl 性能可以与 C 相媲美;然而,这显然不是其中一种情况。Perl 是一种很好的通用语言,它的优点之一是能够跳出语言并在较低级别的语言中实现代码。当您真正需要提高性能时,不要害怕语言混合,只要您了解存在维护成本即可。引入其他语言的缺点是,它会增加将来必须维护应用程序的人员的负担。他们需要了解 C 并理解 XS 函数。但是,在我们的例子中,改进的性能明显超过了支持 XS 的影响。
Bruce W. Lowther (blowther@micron.com) 是爱达荷州博伊西市 Micron Technology, Inc. 的软件工程师。他在 Micron 工作了九年,并且在过去五年中一直致力于开发工具,以帮助将半导体设备集成到 Micron 制造流程中。他获得了爱达荷大学计算机科学专业的本科和硕士学位。