作者: whooyun发表于: 2017-04-05 17:00
微基准测试看起来很好,但要写对却很困难。考虑以下代码,被测的方法是计算出第 50个斐波那契数,这段代码试图用微基准测试来测试不同实现的性能:
public void doTest() { // 主循环 double l; long then = System.currentTimeMillis(); for (int i = 0; i < nLoops; i++) { l = fibImpl1(50); } long now = System.currentTimeMillis(); System.out.println("Elapsed time: " + (now - then)); } ... private double fibImpl1(int n) { if (n < 0) throw new IllegalArgumentException("Must be > 0"); if (n == 0) return 0d; if (n == 1) return 1d; double d = fibImpl1(n - 2) + fibImpl(n - 1); if (Double.isInfinite(d)) throw new ArithmeticException("Overflow"); return d; }
代码看起来简单,却存在很多问题。
1. 必须使用被测的结果
这段代码的最大问题是,实际上它永远都不会改变程序的任何状态。因为斐波那契的计算结果从来没有被使用,所以编译器可以很放心地去除计算结果。智能的编译器(包括当前的 Java 7 和 Java 8)最终执行的是以下代码:
long then = System.currentTimeMillis(); long now = System.currentTimeMillis(); System.out.println("Elapsed time: " + (now - then));结果是,无论计算斐波那契的方法如何实现,循环执行了多少次,实际的流逝时间其实只有几毫秒。循环如何被消除的细节请参见第 4 章。
2. 不要包括无关的操作
即便使用了被测结果,依然还有隐患。上述代码只有一个操作:计算第 50 个斐波那契数。可想而知,其中有些迭代操作是多余的。如果编译器足够智能的话,就能发现这个问题,从而只执行一遍循环——至少可以少几次迭代,因为那些迭代是多余的。另外, fibImpl(1000) 的性能可能与 fibImpl(1) 相差很大。如果目的是为了比较不同实现的性能,测试的输入就应该考虑用一系列数据。也就是说,解决这个问题,需要给 fibImpl1() 传入不同的参数。可以使用随机值,但仍然必须小心。
下面是种简单方法,即在循环中使用随机数生成器:
for (int i = 0; i < nLoops; i++) { l = fibImpl1(random.nextInteger()); }可以看到,循环中包括了计算随机数,所以测试的总时间是计算斐波那契数列的时间,加上生成一组随机数的时间。这可不是我们的目的。微基准测试中的输入值必须事先计算好,比如:
int[] input = new int[nLoops]; for (int i = 0; i < nLoops; i++) { input[i] = random.nextInt(); } long then = System.currentTimeMillis(); for (int i = 0; i < nLoops; i++) { try { l = fibImpl1(input[i]); } catch (IllegalArgumentException iae) { } } long now = System.currentTimeMillis();3. 必须输入合理的参数
public double fibImplSlow(int n) { if (n < 0) throw new IllegalArgumentException("Must be > 0"); if (n > 1476) throw new ArithmeticException("Must be < 1476"); return verySlowImpl(n); }
虽然很难想象会有比原先用递归更慢的实现,但我们不妨假定有这么个实现并用在了这段代码里。通过大量输入值比较这两种实现,我们会发现,新的实现竟然比原先的实现快得多——仅仅是因为在方法开始时进行了范围检查。如果在真实场景中,用户只会传入小于 100 的值,那这个比较就是不正确的。通常情况下fibImpl() 会更快,正如第 1 章所说,我们应该为常见的场景进行优化。(显然这是个精心构造的例子。不管怎样,仅仅在原先的实现上添加了边界测试就使得性能变好,通常这是不可能的。)
综合所有因素,正确的微基准测试代码看起来应该是这样:
package net.sdo; import java.util.Random; public class FibonacciTest { private volatile double l; private int nLoops; private int[] input; public static void main(String[] args) { FibonacciTest ft = new FibonacciTest(Integer.parseInt(args[0])); ft.doTest(true); ft.doTest(false); } private FibonacciTest(int n) { nLoops = n; input = new int[nLoops]; Random r = new Random(); for (int i = 0; i < nLoops; i++) { input[i] = r.nextInt(100); } } private void doTest(boolean isWarmup) { long then = System.currentTimeMillis(); for (int i = 0; i < nLoops; i++) { l = fibImpl1(input[i]); } if (!isWarmup) { long now = System.currentTimeMillis(); System.out.println("Elapsed time:" + (now - then)); } } private double fibImpl1(int n) { if (n < 0) throw new IllegalArgumentException("Must be > 0"); if (n == 0) return 0d; if (n == 1) return 1d; double d = fibImpl1(n - 2) + fibImpl(n - 1); if (Double.isInfinite(d)) throw new ArithmeticException("Overflow"); return d; } }
甚至这个微基准测试的测量结果中也仍然有一些与计算斐波那契数没有太大关系:调用fibImpl1() 的循环和方法开销,将每个结果都写到 volatile 变量中也会有额外开销。此外还需要留意编译效应。编译器编译方法时,会依据代码的性能分析反馈来决定所使用的最佳优化策略。性能分析反馈基于以下因素:频繁调用的方法、调用时的栈深度、方法参数的实际类型(包括子类)等,它还依赖于代码实际运行的环境。编译器对于相同代码的优化在微基准测试中和实际应用中经常有所不同。如果用相同的测试衡量斐波那契方法的其他实现,就能看到各种编译效应,特别是当这个实现与当前的实现处在不同的类中时。
最终,还要探讨微基准测试实际意味着什么。比如这里讨论的基准测试,它有大量的循环,整体时间以秒计,但每轮循环迭代通常是纳秒级。没错,纳秒累计起来,“积少成多”就会成为频繁出现的性能问题。特别是在做回归测试的时候,追踪级别设为纳秒很有意义。如果集合操作每次都节约几纳秒,日积月累下来意义就很重大了(示例参见第 12章)。对于那些不频繁的操作来说,例如那种同时只需处理一个请求的 servlet,修复微基准测试所发现的纳秒级性能衰减就是浪费时间,这些时间用在优化其他操作上可能会更有价值。
微基准测试难于编写,真正管用的又很有限。所以,应该了解这些相关的隐患后再做出决定,是微基准测试合情合理值得做,还是关注宏观的测试更好。