热爱测试,主要研究性能测试和自动化测试方面的技术,希望与同样对测试有热情的你一同进步成长

转:健壮的 Java 基准测试

上一篇 / 下一篇  2012-09-14 17:23:52 / 个人分类:性能测试

健壮的 Java 基准测试,第 1 部分: 问题

51Testing软件测试网,x^2Bn;YO6B

了解 Java 代码基准测试的问题

/g&i0Vzf#`X0
Brent Boyer, 程序员, Elliptic Group, Inc.

V P G*@m$Qy ^0

p4W|2GcT/x8\x-q3l$E0

简介:程序性能一直是受到关注的问题,即使在现在这样的高性能硬件时代,也是如此。本文是分两部分的文章系列的第一篇,讨论与 Java™ 代码基准测试相关的许多问题。第 2 部分讨论基准测试的统计并提供一个执行 Java 基准测试的框架。因为几乎所有新语言都是基于虚拟机的,所以本文讨论的基本原则适用于许多编程语言。

Xd*y9?/E0

查看本系列更多内容51Testing软件测试网(|pG5fm(u:j D/U

51Testing软件测试网V|W!~9{

51Testing软件测试网-`R-ZL@z$zR@

发布日期:2008 年 7 月 09 日
#Rs|eW}0级别:高级其他语言版本:英文51Testing软件测试网5O3@2x!|$z-I!b'|x
访问情况 :3022 次浏览
d!ARy0l7v3u1T3u2~w0评论:0 (查看|添加评论- 登录)

平均分 4 星 共 4 个评分平均分 (4个评分)51Testing软件测试网$G nI q}Ats5|
为本文评分
51Testing软件测试网^b5}Y,b,krf&D

w#{g o"r:i4Vw0

-Z&{#I I'wr ~H0
51Testing软件测试网S2p*I^i1K)pK

当今的 CPU 速度已经达到数 GHz,出现了多核处理器和数 GB 的内存,即使在这样的时代,程序性能问题仍然受到持续的关注。随着硬件功能的每次提高,都会出现具有挑战性的新型应用程序(或者增加了程序员的 “惰性”)。基准测试代码(以及从基准测试结果得出正确的结论)总是存在问题和困难,而且几乎没有比 Java 更难进行基准测试的语言,尤其是在先进的现代虚拟机上。

5JV_&^^2Wu.g \0

9OL |[{&g0m&Q5O0这个分两部分的文章系列只讨论程序执行时间,不考虑执行程序时的其他重要性质,比如内存使用量。即使在如此狭义的性能定义之下,精确地进行代码基准测试仍然有很多困难。这些问题的数量和复杂性使大多数基准测试都不太精确,常常导致误解。本文的第一部分只讨论这些问题,并给出了在编写自己的基准测试框架时需要考虑的各个方面。

8Ws2z7b t,qV/iB0c0

一个性能难题

[gMw*F ]*H0

我首先通过一个性能难题演示一些基准测试方面的问题。请考虑清单 1 中的代码(参见参考资料获得本文的完整示例代码链接):

2SQ[U!|A&AK051Testing软件测试网 oC,w{lF!bi
清单 1. 性能难题51Testing软件测试网"U/M+e8\qt
protected static int global;

public static void main(String[] args) {
    long t1 = System.nanoTime();

    int value = 0;
    for (int i = 0; i < 100 * 1000 * 1000; i++) {
        value = calculate(value);
    }

    long t2 = System.nanoTime();
    System.out.println("Execution time: " + ((t2 - t1) * 1e-6) + " milliseconds");
}

protected static int calculate(int arg) {
    //L1: assert (arg >= 0) : "should be positive";
    //L2: if (arg < 0) throw new IllegalArgumentException("arg = " + arg + " < 0");

    global = arg * 6;
    global += 3;
    global /= 2;
    return arg + 2;
}
51Testing软件测试网#j9m aTE l#Q\L!s2o

R F[ A$?9\$QV0以下哪个版本运行得最快呢?

1zd.Iv1W,|6F}$C/J0
  1. 保持此代码不变(calculate中没有arg测试)
  2. 只取消L1行的注释标志,但是禁用断言(使用-disableassertionsJVM 选项;这也是默认行为)
  3. 只取消L1行的注释标志,但是启用断言(使用-enableassertionsJVM 选项)
  4. 只取消L2行的注释标志

您至少应该猜出 A 版本(没有测试)一定是最快的,还可能猜到 B 应该与 A 差不多一样快,因为在关闭断言的情况下,L1行是死代码,良好的动态优化编译器应该会消除它。这种猜测对吗?不幸的是,可能错了。清单 1中的代码取自 Cliff Click 在 2002 JavaOne 上的发言稿(参见参考资料)。他的幻灯片报告了下面的执行时间:51Testing软件测试网OKu2c P

  1. 5 秒
  2. 0.2 秒
  3. (他没有报告这种情况下的数据)
  4. 5 秒

k DC9p8w*waQ)G0当然,最让人吃惊的是 B。它怎么会比 A 快 25 倍呢?51Testing软件测试网/{"{4e5o)m,_

6 年后,我在下面的现代配置上运行了清单 1中的代码(除非另外说明,本文中的所有基准测试结果都采用这种配置):51Testing软件测试网8B!f ?@;R|Ym.A

  • 硬件:2.2 GHz Intel Core 2 Duo E4500,2 GB RAM
  • 操作系统:Windows® XP SP2,包含到 2008 年 3 月 13 日为止的所有更新
  • JVM:1.6.0_05,所有测试都使用-server选项
51Testing软件测试网"V#W6Z(EC v

我得到了以下结果:

H"]A!x'D*o*g?0
  1. 38.601 ms
  2. 56.382 ms
  3. 38.502 ms
  4. 39.318 ms
51Testing软件测试网X,hMN1@5|{9D7k5u

B 现在明显比 A、C 和 D 慢。但是,结果仍然很奇怪:B 应该与 A 差不多,可是它比 C 还慢,这很让人吃惊。注意,我对每个版本做了 4 次度量,都获得了大体类似的结果(偏差在 1 ms 之内)。

ZgM.aDft%t$o8d%@0

7~M'h(L ]O'j8?R'M0Click 的幻灯片讨论了为什么会得到奇怪的结果(他把这种现象归因于复杂的 JVM 行为;还牵涉到一个 bug)。Click 是 HotSpot JVM 的架构师,所以他的解释应该是合理的。但是,普通的程序员有办法进行正确的基准测试吗?

f(E;G @1gsN"xq0

答案是肯定的。在本文的第 2 部分中,我将提供一个 Java 基准测试框架,您可以放心地下载和使用它,因为它处理了许多基准测试问题。这个框架很容易满足大多数基准测试需求:只要把目标代码打包成特定类型的任务对象(CallableRunnable),然后调用Benchmark类。其他所有工作(性能度量、统计数据计算和结果报告)都会自动完成。

^ag b3y8x0

为了演示这个框架的使用方法,我把main替换为清单 2 中的代码,从而重新对清单 1中的代码进行基准测试:

w#| @Vi w3Mfn$aY051Testing软件测试网7C(U c*`)Y g
清单 2. 使用Benchmark解决性能难题
iTVtf8Y0
public static void main(String[] args) throws Exception {
    Runnable task = new Runnable() { public void run() {
        int value = 0;
        for (int i = 0; i < 100 * 1000 * 1000; i++) {
            value = calculate(value);
        }
    } };
    System.out.println("Cliff Click microbenchmark: " + new Benchmark(task));
}

"_G:UZ7x%i"a051Testing软件测试网:r9h2]a$J,c]j

在我的配置上运行此代码产生了以下结果:

C _!f z"ld0
  1. mean = 20.241 ms ...
  2. mean = 20.246 ms ...
  3. mean = 26.928 ms ...
  4. mean = 26.863 ms ...
51Testing软件测试网_/V4e+kV;C7\f(H

结果终于符合预期了:A 和 B 的执行时间基本相同。C 和 D(它执行相同的参数检查)的执行时间也差不多,但是长一点儿。

5y0rgCB0O0

在这种情况下,使用Benchmark获得了预期的结果,这可能是因为它在内部执行task许多次,丢弃出现稳定的执行状态之前的 “预热(warmup)” 数据,然后执行一系列精确的度量。与之相反,清单 1中的代码马上开始度量执行时间,这意味着它的结果与实际代码的执行时间关系不大,但与JVM 行为密切相关。尽管在上面的结果中省略了(由 ... 表示),但是Benchmark还执行一些有意义的统计计算,这些计算表明了结果的可靠性。

3oV"\Aj%I"Yr0

但是,请不要直接使用这个框架。您应该在一定程度上熟悉本文,尤其是熟悉与动态优化有关的一些复杂问题,以及第 2 部分中讨论的一些解释问题。不要盲目地相信任何数字。要了解这些数字是如何获得的。

W b g1n/yxr]0

O+]qTn6Vg0

执行时间度量51Testing软件测试网Nx rGbMv

51Testing软件测试网Z?yM s4t'R*^ b

从原理上看,度量代码的执行时间很简单:

lBRUP5yq0
  1. 记录开始时间。
  2. 执行代码。
  3. 记录停止时间。
  4. 计算时间差。
51Testing软件测试网-L5o#E4f D}

大多数 Java 程序员可能会编写出与清单 3 相似的代码:51Testing软件测试网3fW@(e E;P9p F}5V o

51Testing软件测试网$Rx5x9_$\%w_{~ACC
清单 3. 典型的 Java 基准测试代码51Testing软件测试网#KmW V r`~%\ o
long t1 = System.currentTimeMillis();
task.run();    // task is a Runnable which encapsulates the unit of work
long t2 = System.currentTimeMillis();
System.out.println("My task took " + (t2 - t1) + " milliseconds to execute.");
51Testing软件测试网0t YZA(PW;FM"X6v
51Testing软件测试网/Wx${%Dg.H3G6^l

清单 3 的方法对于长时间运行的任务常常很合适。例如,如果task所需的执行时间达到一分钟,那么下面讨论的分辨率问题可能不明显。但是,随着task执行时间的降低,这段代码会越来越不精确。基准测试框架应该能够自动处理任何task,所以清单 3 并不合适。51Testing软件测试网@2zDJ!y6QF P

一个问题是分辨率:System.currentTimeMillis表示返回的结果具有名义上的毫秒级分辨率(参见参考资料)。如果假设结果包含随机的 ±1 ms 误差,并希望执行时间的度量误差不超过 1%,那么对于执行时间等于或小于 200 ms 的任务,System.currentTimeMillis就不能满足分辨率需求(因为两次度量涉及两个误差,误差的和可能达到 2 ms)。51Testing软件测试网!D\h9F |K H0I

rbP \&m$x;w*W1\ u'C0在真实环境中,System.currentTimeMillis的分辨率可能会糟糕 ~10-100 倍。它的 Javadoc 指出:

!{9l'ctx#} A4a1U0
注意,尽管返回值的时间单位是毫秒,但是值的粒度取决于底层操作系统,甚至可能比操作系统的时间单位更大。例如,许多操作系统以几十毫秒作为时间度量的单位。

+@,H yb%V0已经报告的分辨率数据见表 1:

J,zQt Y0
E|fxXn V:r0表 1. 分辨率
#v;{A6rL,BU0
分辨率平台来源(参见参考资料
55 msWindows 95/98Java Glossary
10 msWindows NT, 2000, XP 单处理器Java Glossary
15.625 msWindows XP 多处理器Java Glossary
~15 msWindows(可能是指 XP)Simon Brown
10 msLinux 2.4 内核Markus Kobler
1 msLinux 2.6 内核Markus Kobler

?0x,m ];bV,[Pi D0

所以,对于执行时间小于 10 秒的任务,清单 3中的代码很容易出现过大的误差。

:M TBqTJ1DL2q A051Testing软件测试网 _!Sh)B0ck.M(i

System.currentTimeMillis的最后一个问题是,假设它反映 “墙上时钟” 的时间,这个问题甚至会影响长时间运行的任务。这意味着,由于标准时间到夏时制的转换或 Network Time Protocol(NTP)同步等事件,它的值偶尔会有突变(向前或向后)。这些调整虽然很少出现,但是可能导致错误的基准测试结果。

3d2ZN{vp0

JDK 1.5 引入了一个分辨率更高的 API:System.nanoTime(参见参考资料)。它名义上返回纳秒数,但是有不确定的偏移量。它的关键特性包括:51Testing软件测试网uEaM\/Fj

  • 它只适用于度量时间差。51Testing软件测试网(UC9{x9v

    Zu$xm x Lj'P0
  • 它的精确性和精度(参见参考资料)应该不会比System.currentTimeMillis差,但是在一些平台上与System.currentTimeMillis相同。
    %rW"q2j;P;[:Mb0
    l)`Ak$w&o5Q-k0
  • 在现代硬件和操作系统上,它可以提供微秒级的精确性和精度。51Testing软件测试网$r9~]uw1r

    #gV$m+I4Qf6^ot'F0

ZG_3P*e4b!n0结论:基准测试应该坚持使用System.nanoTime,因为它通常具有更好的分辨率。但是,它可能并不比System.currentTimeMillis好,基准测试代码必须处理这种可能性。

+a-o6g'@2X0

JDK 1.5 还引入了ThreadMXBean接口(参见参考资料)。它有几个功能,但是它的getCurrentThreadCpuTime方法与基准测试的关系尤其密切(参见参考资料)。这个方法不度量流逝(“墙上时钟”)时间,而是度量当前线程使用的实际 CPU 时间(这个时间小于或等于流逝时间)。51Testing软件测试网)}8J,hc$V x0T

51Testing软件测试网$E5pT QB2oSN

不幸的是,getCurrentThreadCpuTime也有一些问题:

!\p9WPB]F0
  • 您的平台可能不支持它。51Testing软件测试网*}!W$hO9{#Dz!Z

    9|!s'\kxDC4|XQ`0
  • 在支持它的不同平台上,它的语义有差异(例如,对于使用 I/O 的线程,它的 CPU 时间可能包括执行 I/O 的时间,但是 I/O 时间也可能算在操作系统线程上)。51Testing软件测试网%s#Ip \;b2u9q |B!A
    51Testing软件测试网MS.gY ]T'z
  • ThreadMXBeanJavadoc 提出了以下警告:“在某些 Java 虚拟机实现中,启用线程 CPU 时间度量可能导致很大的开销”。(这是一个与操作系统相关的问题。在某些操作系统上,度量线程 CPU 时间所需的 microaccounting 总是打开的,所以getCurrentThreadCpuTime没有额外的性能影响。其他操作系统在默认情况下关闭这个特性;如果启用它,它会降低进程中的所有线程或所有进程的性能)。51Testing软件测试网8bW:E`Z `gu'_

    U"Z7m@ebF0
  • 它的分辨率不明确。因为它返回的结果具有名义上的纳秒级分辨率,自然会认为它的精确性和精度限制与System.nanoTime相同。但是,我没有找到证明这一点的任何文档,而且有一个报告指出它的精度更低(参见参考资料)。我对getCurrentThreadCpuTimenanoTime做了对比试验,发现前者产生的平均执行时间比较小。在我的桌面电脑配置上,执行时间大约降低了 0.5%-1%。不幸的是,度量漂移量很大;例如,标准差很容易增加到三倍。在一台 N2 Solaris 10 机器上,执行时间降低了 5%-10%,度量漂移量没有增加(有时候出现大幅度降低)。51Testing软件测试网w,b"J:g,NS%m
    51Testing软件测试网|8L}UJ;@J
  • 最糟糕的是:当前线程使用的 CPU 时间可能是不相关的。例如,一个任务有一个进行调用的线程(将度量这个线程的 CPU 时间),它仅仅建立一个线程池,然后把一些子任务发送给这个池,然后就一直空闲着,直到池完成任务。进行调用的线程所用的 CPU 时间会非常少,而完成这个任务所需的时间可以无限长。因此,报告这个时间会导致严重的误解。

由于有这些问题,在通用的基准测试框架上默认使用getCurrentThreadCpuTime太危险了。第 2 部分中提供的Benchmark类要求通过一个特殊配置来启用它。51Testing软件测试网.D A ]c8?w0z$`a$F!L

所有时间度量 API 需要注意的问题:它们都有执行开销,如果过于频繁地执行这些 API,就会严重歪曲度量值。这个问题的影响高度依赖于平台。例如,在 Windows 的现代版本中,System.nanoTime涉及一个执行时间为微秒级的操作系统调用,所以调用它的频率不应该高于每 100 微秒一次,否则对度量的影响就会超过 1%。(相反,System.currentTimeMillis只需要读取一个全局变量,所以执行得非常快,是纳秒级的。如果仅仅考虑对度量的影响,可以更频繁地调用它;但是,这个全局变量的更新没这么频繁,根据表 1来看,大约是每 10 到 15 毫秒一次,所以频繁地调用它是没有必要的)。另一方面,在大多数 Solaris(和某些 Linux®)机器上,System.nanoTime常常比System.currentTimeMillis执行得快。

(h1AnA]$K(V1cj0

.B3RVQ {Vp+?I0

代码预热

%cAq1p2z0

一个性能难题一节中,我指出Benchmark产生更可靠的结果的原因是,它只度量稳定状态下task的执行时间,而不理会最初的性能。大多数 Java 实现具有复杂的性能生命周期。一般来说,最初的性能往往相当低,然后性能显著提高(常常出现几次性能跃升),直到到达稳定状态。假设希望度量稳定状态下的性能,就需要了解影响这个过程的所有因素。51Testing软件测试网9OWb^ A(q;Z;U

类装载51Testing软件测试网tF1f-wd&@+C

w)d.`YVM3K1a0JVM 通常只在类的第一次使用类时装载它们。所以,task的第一次执行时间包含装载它使用的所有类的时间(如果这些类还没有装载的话)。因为类装载往往涉及磁盘 I/O、解析和检验,这会显著增加task的第一次执行时间。常常可以通过多次执行task来消除这种影响。(我说常常—— 而不是总是,这是因为task可能具有复杂的分支行为,这可能导致它在任何给定的执行过程中并不使用所有可能用到的类。幸运的是,如果执行任务足够多次,就可能经历所有分支,因此很快就会装载所有相关类)。

}{u/Z9b3s%G9_0x\0

\NJ t7o0如果使用定制的类装载器,就有另一个问题:JVM 可能认为一些类已经成了垃圾,因此决定卸载它。这不太可能严重影响性能,但是仍然会使基准测试结果产生偏差。

};zr+].V |9QlAw:Qy0

可以在基准测试之前和之后调用ClassLoadingMXBeangetTotalLoadedClassCountgetUnloadedClassCount方法,以此判断在基准测试过程中是否发生了类装载/卸载(参见参考资料)。如果两次的结果不同,就是还未达到稳定状态。

2UI[dz9lj:|0

混合模式51Testing软件测试网6M$^_ o;B/x6Rz

在执行即时(Just-in-time,JIT)编译之前,现代的 JVM 通常会运行代码一段时间(常常是纯解释式运行),从而收集剖析信息(参见参考资料)。这对基准测试的影响在于,任务可能需要执行许多次,才能达到稳定状态。例如,Sun 的客户机/服务器 HotSpot JVM 当前的默认行为是,必须对一个代码块进行 1,500(客户机)或 10,000(服务器)次调用,之后才对包含这个代码块的方法进行 JIT 编译。

FU^h?Fg0

注意,我在这里使用了一个一般性短语 “代码块(code block)”,这不仅仅可以指完整的方法,还可以指一个方法中的块。例如,许多先进的编译器可以识别出构成 “热” 代码的循环代码块,即使它只包含对包含方法的一个调用。我将在本文的堆栈上替换一节中详细解释这一点。51Testing软件测试网l0N"dn\i

co%d w:oK0因此,对稳定状态下的性能进行基准测试需要以下步骤:51Testing软件测试网d)j$R}S?w

  1. 执行task一次,以便装载所有类。
  2. 执行task足够多次,以确保出现稳定状态的执行数据。
  3. 再多执行task几次,以获得执行时间的估计值。
  4. 使用步骤 3 计算n,这是执行task的次数,这些次执行的累计时间必须足够大。
  5. 度量对taskn次调用的总执行时间t
  6. 估算执行时间,t/n

y%_2aO7j~ z0度量taskn次执行的目的在于,让累计的执行时间足够大,从而减少前面讨论的所有时间度量误差的影响。

3L m[X H+wP/O0

)zMYh H\cv0h0步骤 2 比较棘手:怎么能够知道 JVM 什么时候完成了对这个任务的优化?

pxO0tLq L G+e&k0

一种看似聪明的方法是不断度量执行时间,直到结果值收敛。这种方式似乎很好,但是如果 JVM 正在收集剖析信息,然后在您开始步骤 5 之后突然应用剖析信息执行 JIT 编译,这种方法就无效了;这在未来更可能引起问题。51Testing软件测试网!n{$t$l.`d6[ J @

51Testing软件测试网P+Q*T3K*d

另外,如何量化 “收敛” 的概念呢?51Testing软件测试网 Fvl,{_^"N

连续编译?

目前,Sun 的 HotSpot JVM 只执行一个剖析阶段,然后可能执行编译。如果忽略去优化,当前并不执行连续编译,这是因为把剖析代码放在热点方法中开销太大了(参见参考资料)。51Testing软件测试网1t-]tZu g6~

对于剖析开销问题,存在一些解决方案。例如,JVM 可以保留方法的两个版本:一个不包含剖析代码的快速版本和执行剖析的慢速版本(参见参考资料)。JVM 在大多数时候使用快速版本,只是偶尔使用慢速版本来维护剖析信息,这样就不会对性能产生显著影响。JVM 还可以在另一个处理器核空闲时并发执行慢速版本。这样的技术在未来可能使连续编译成为常规做法。51Testing软件测试网 }_h+J)bJ:S!c\{0\

51Testing软件测试网0M7\7E I6_

另一种方法是在一个预先确定的长度合理的时间段内连续执行任务(Benchmark类就使用这种方法)。10 秒的预热阶段应该足够了(参见 Click 发言稿的第 33 页)。这种方法可能并不比度量执行时间直至收敛的方法更可靠,但是更容易实现。它还更容易参数化:用户应该很容易理解这种方法的概念,而且知道预热时间越长,结果就越可靠(但以长时间的基准测试为代价)。

7X6arC^p051Testing软件测试网)?;K5o J Nb1b"CR

如果可以判断什么时候 JIT 编译,就可以更有把握地确定稳定状态性能。尤其是,如果您认为已经到了稳定状态并开始基准测试,但是随后发现在基准测试期间发生了编译,那么可以中止并重试。

E%M#^%t J%nHv/u(L0

根据我的知识,还没有探测 JIT 编译是否发生的完美方法。最好的技术是在基准测试之前和之后调用CompilationMXBean.getTotalCompilationTime。不幸的是,CompilationMXBean的实现非常拙劣,所以这种方法有许多问题。另一种技术是,在使用-XX:+PrintCompilationJVM 选项的情况下,解析(或人工观察)stdout(参见参考资料)。

E@JZIq$m!neZc d0

动态优化51Testing软件测试网-A1k!u0AZ6w8H i

除了预热问题之外,JVM 的动态编译涉及另外几个影响基准测试的问题。这些问题很微妙。而且更糟糕的是,只能靠基准测试程序员来解决这些问题,基准测试框架对此没有帮助。(本文的缓存准备两节也讨论一些由基准测试程序员负责解决的问题,但是这些问题基本上靠常识就能够解决)。51Testing软件测试网w}(m+u"q;]

去优化51Testing软件测试网m6Y;M PV B6_(n

另一个问题是去优化(参见参考资料):编译器可以停止使用已编译的方法,并对它进行一段时间的解释,然后重新编译它。当执行优化的动态编译器做出的假设已经过时时,就会发生这种情况。一个例子是使单态调用转换失效的类装载。另一个例子是不常用的分支:在最初编译一个代码块时,只编译最常用的代码路径,而不常用的分支(比如异常路径)仍然采用解释方式。但是,如果不常用的分支变成了经常执行的,它们就成了热点,这会触发重新编译。

@5?Z G6C8r3~051Testing软件测试网i/mf1K t\t P

因此,即使按照前一节中的建议实现了稳定状态,也要注意性能仍然可能突然下降。这是需要探测在基准测试期间是否发生 JIT 编译的另一个原因。

7[5HM:?I1N0

堆栈上替换51Testing软件测试网2V'g O+Enq

另一个问题是堆栈上替换(OSR),这种高级 JVM 特性有助于优化某些代码结构(参见参考资料)。请考虑清单 4 中的代码:51Testing软件测试网yJ?`8y0M

51Testing软件测试网 Mo"MjE,m
清单 4. OSR 问题的示例代码
\b p)ohY i:C%}n0
private static final int[] array = new int[10 * 1000];
static {
    for (int i = 0; i < array.length; i++) {
        array[i] = i;
    }
}

public static void main(String[] args) {
    long t1 = System.nanoTime();

    int result = 0;
    for (int i = 0; i < 1000 * 1000; i++) {    // outer loop
        for (int j = 0; j < array.length; j++) {    // inner loop 1
            result += array[j];
        }
        for (int j = 0; j < array.length; j++) {    // inner loop 2
            result ^= array[j];
        }
    }

    long t2 = System.nanoTime();
    System.out.println("Execution time: " + ((t2 - t1) * 1e-9) +
        " seconds to compute result = " + result);
}
51Testing软件测试网Wvm9Zt"w(rou5n

M'T0iO c6O0如果 JVM 只考虑方法调用,那么根本不会使用main的编译版本,因为它只被调用一次。为了解决这个问题,JVM 可以考虑方法内代码块的执行。尤其是,对于清单 4 中的代码,JVM 可以跟踪执行每个循环的次数。(循环的最后一个括号构成一个 “向后分支”)。在默认情况下,任何循环在达到一定的迭代次数(比如 10,000 次)之后,就应该触发整个方法的编译。因为main不会被再次调用,所以简单的 JVM 不会使用它的编译版本。但是,使用 OSR 的 JVM 非常机智,可以把方法调用中的当前代码替换为新的编译代码。51Testing软件测试网F&aL f$U]6E

初看上去,OSR 似乎很不错。好像 JVM 可以处理任何代码结构,同时提供最佳性能。不幸的是,OSR 有一个不太为人所知的缺陷:在使用 OSR 时,代码质量可能是次优的。例如,OSR 有时候无法提升循环、消除数组边界检查或解开循环(参见参考资料)。如果使用 OSR,可能无法得到最佳性能。51Testing软件测试网~I.s3{ z7o O

假设希望获得最佳性能,那么解决 OSR 问题的惟一方法是了解什么时候会出现 OSR,并调整代码结构来避免它。这通常需要把关键的内部循环放在单独的方法中。例如,清单 4中的代码可以改写为清单 5:51Testing软件测试网1o!nyb(ac

51Testing软件测试网 Hhl2NB4z'v\?
清单 5. 改写后的代码不再受 OSR 的影响51Testing软件测试网\'RuT{*}pf4G5H
public static void main(String[] args) {
    long t1 = System.nanoTime();

    int result = 0;
    for (int i = 0; i < 1000 * 1000; i++) {    // sole loop
        result = add(result);
        result = xor(result);
    }

    long t2 = System.nanoTime();
    System.out.println("Execution time: " + ((t2 - t1) * 1e-9) +
        " seconds to compute result = " + result);
}

private static int add(int result) {    // method extraction of inner loop 1
    for (int j = 0; j < array.length; j++) {
        result += array[j];
    }
    return result;
}

private static int xor(int result) {    // method extraction of inner loop 2
    for (int j = 0; j < array.length; j++) {
        result ^= array[j];
    }
    return result;
}

Yhp!o&w0

在清单 5 中,addxor方法会分别被调用 1,000,000 次,所以它们应该会完整地 JIT 编译为优化形式。在我的配置上,这段代码前三次运行的执行时间是 10.81、10.79 和 10.80 秒。而清单 4(所有循环放在main中,因此触发 OSR)的执行时间高了一倍。(前三次的执行时间是 21.61、21.61 和 21.6 秒)。

)~+D/C ^Tt Xf!R3a051Testing软件测试网.nag5Q3\;? k

关于 OSR 的最后一点提示:通常,只有程序员很懒惰,把所有东西都放在一个方法(比如main)中时,它才会给基准测试带来性能问题。在真实的应用程序中,程序员通常会(而且应该)编写许多细粒度的方法。另外,影响性能的代码通常会长时间运行,并涉及多次调用关键方法。所以,真实的代码通常不会受到 OSR 性能问题的影响。在您的应用程序中,不需要过分担心这个问题,不必为此破坏优雅的代码(除非可以证明它确实造成了损害)。注意,Benchmark在默认情况下会多次执行任务来收集统计数据,多次执行的副作用是消除 OSR 对性能的影响。

)k T UM/t2u{2g0

消除死代码51Testing软件测试网UE@8g-^ vF

另一个微妙的问题是消除死代码(DCE),参见参考资料。在某些情况下,编译器可以判断出某些代码根本不影响输出,所以编译器会消除这些代码。清单 6 给出一个静态执行(即在编译时由javac执行)死代码消除的典型示例:

Z.T,\(C8iB(f051Testing软件测试网 Ck%`[C&C#Kv
清单 6. 受 DCE 影响的示例代码
3t'J5jD;S%E0
private static final boolean debug = false;

private void someMethod() {
    if (debug) {
        // do something...
    }
}
51Testing软件测试网*Ym;wb.d r[-eY1i
51Testing软件测试网jHW$?*cq

javac知道清单 6 中if (debug)块中的代码根本不会执行,所以会消除它。动态编译器(尤其是在进行方法内联之后)通过许多方法来判断死代码。DCE 在基准测试期间造成的问题是,执行的代码可能只是全部代码的一个小子集 — 完整的编译可能不会发生 — 这会导致错误地报告很短的执行时间。

V X{8mX ez8g D0^,K0

我还没有找到出色地描述编译器用来判断死代码的所有条件的文档(参见参考资料)。不可达代码显然是死代码,但是JVM 采用的 DCE 策略常常更激进

mbhWfS2mg0

例如,请重新考虑清单 4中的代码:注意,main不只计算result,而且在输出中使用result。假设进行一个简单修改,从println中删除result。在这种情况下,激进的编译器可能认为它根本不需要计算result51Testing软件测试网\s!k D3P2G

"YW%kY\*uxk?'l;f _q0这不是一个单纯的理论问题。请考虑清单 7 中的代码:51Testing软件测试网8_5hlS&E%U-c re

51Testing软件测试网+T0EP \Y5K!zE:?(a u
清单 7. 通过在输出中使用result停止 DCE
u"b*[ Hze0
public static void main(String[] args) {
    long t1 = System.nanoTime();

    int result = 0;
    for (int i = 0; i < 1000 * 1000; i++) {    // sole loop
        result += sum();
    }

    long t2 = System.nanoTime();
    System.out.println("Execution time: " + ((t2 - t1) * 1e-9) +
        " seconds to compute result = " + result);
}

private static int sum() {
    int sum = 0;
    for (int j = 0; j < 10 * 1000; j++) {
        sum += j;
    }
    return sum;
}

}h!gX8| C0

清单 7 中的代码在我的配置上的执行时间是总是 4.91 秒。如果删除println语句中对result的引用(代码变成System.out.println("Execution time: " + ((t2 - t1) * 1e-9) + " seconds to compute result");),执行时间就是 0.08 秒。显然,DCE 消除了整个计算过程。(另一个 DCE 示例参见参考资料)。

&peDKki.ry051Testing软件测试网-x4s8r(zj/Yk"@_

要想保证 DCE 不会消除您希望进行基准测试的计算,惟一的方法是让计算生成结果,然后以某种方式使用结果(例如,像清单 7 中的println那样在输出中使用)。Benchmark类支持这种做法。如果任务是Callable,就要确保call()方法返回计算所获得的结果。如果任务是Runnable,就要确保任务的toString方法(这个方法必须覆盖Object对象的方法)使用的某个内部状态是用这个计算获得的。如果遵守这些规则,Benchmark应该会完全防止 DCE。51Testing软件测试网 HKo@E%OET

#E~W/ae3m^0与 OSR 一样,对于真实的应用程序 DCE 常常不是问题(除非您希望在特定时间内执行代码)。但是与 OSR 不同,DCE 对于编写得很糟糕的基准测试会造成严重问题:OSR 只会使结果不太精确,而DCE 可能导致完全错误的结果51Testing软件测试网's5h;hu-bd0e


9pc4OjO$d|,y0

资源回收51Testing软件测试网cY^BJ Lb I

.L"dI;Xx.ec e1v0典型的 JVM 会自动执行两种资源回收:垃圾收集和对象终结(GC/OF)。从程序员的角度来看,GC/OF 几乎是不确定的:它在根本上不受您的控制,可以在 JVM 认为需要的任何时候发生。51Testing软件测试网.P3bF&HK

51Testing软件测试网QVFl:SRK:V

在基准测试中,结果应该包含由于任务本身造成的 GC/OF 时间。例如,如果仅仅因为任务的最初执行时间很短,就认为这个任务很快,可能是不可靠的,因为它最终可能产生很大的 GC 时间。(但是注意,一些任务不需要创建对象。相反,它们只需访问已经创建的对象。假设一次基准测试希望度量出访问某个数组元素所用的时间:这个任务应该不用创建数组。相反,应该在其他地方创建数组,这个任务可以使用数组的引用)。51Testing软件测试网:JGT2x+WD

a0U7FLr%Cs`+U0但是,还需要把任务的 GC/OF 与同一 JVM 会话中其他代码造成的 GC/OF 分开。惟一的方法是在执行基准测试之前尝试清理 JVM,还要尝试确保任务本身的 GC/OF 在度量结束前完全完成。

k_6m S P)Y0

X \Le |9~l0System类提供了gcrunFinalization方法,可以用这些方法清理 JVM。但是注意,这些方法的 Javadoc 仅仅声明 “当控制从方法调用返回时,Java 虚拟机会尽可能执行 GC/OF”。

ZVR lv5Q x3x0

第 2 部分中提供的Benchmark类按照以下步骤处理 GC/OF:

9` ]6R)}2[b/w0
  1. 在执行任何度量之前,它调用cleanJvm方法,这个方法根据需要多次调用System.gcSystem.runFinalization,直到内存使用量稳定下来,并且所有对象已经终结。
    8@:S:xQ1j {9M0
    (Gp3v5~*VF(r+k0
  2. 在默认情况下,它执行 60 次执行度量,每次至少持续 1 秒(如果必要的话,对于每次度量多次调用任务,以此确保时间达到 1 秒)。所以总的执行时间应该至少 1 分钟,这么长的时间应该可以确保把足够的 GC/OF 生命周期包含在 60 次度量中,从而精确地度量任务的完整情况。
    jEp-S(dm4WF)}/a0
    9@&A?8m'e9b\8k0
  3. 完成所有度量之后,最后一次调用cleanJvm,但是这一次度量这个调用所花的时间。如果这个最终清理步骤花费的时间超过任务总执行时间的 1%,基准测试报告就会警告说,度量可能没有充分考虑 GC/OF 成本。
    Tu(oa [T6B0
    $?&HJS[k"xPs8I0
  4. 因为 GC/OF 对于每次度量来说就像是噪音源,所以使用统计数据来提取可靠的结果。

1r%v:H.A6zJ0一个注意事项:在我最初编写Benchmark时,尝试用清单 8 中的代码在每次度量中考虑 GC/OF 成本:51Testing软件测试网[ {|1ON%V9n{1N


+|ts#_7]p0清单 8. 考虑 GC/OF 成本的错误方法51Testing软件测试网tq5e3ugbP
protected long measure(long n) {
    cleanJvm();    // call here to cleanup before measurement starts

    long t1 = System.nanoTime();
    for (long i = 0; i < n; i++) {
        task.run();
    }
    cleanJvm();    // call here to ensure that task's GC/OF is fully included
    long t2 = System.nanoTime();
    return t2 - t1;
    }
51Testing软件测试网NddBt4pw

问题在于,在度量循环内调用System.gcSystem.runFinalization会歪曲 GC/OF 成本。尤其是,System.gc会用一个stop-the-world收集器对所有代进行一次全面的垃圾收集(参见参考资料)。(这是默认行为,但是也可以通过-XX:+ExplicitGCInvokesConcurrent-XX:+DisableExplicitGC等 JVM 选项来控制)。而实际上,应用程序通常所用的垃圾收集器的操作方式可能很不一样。例如,它可能被配置成并发地工作,可能执行成本很小的许多次部分收集(特别针对年轻的代)。同样,终结通常是后台任务,所以它们常常在系统的空闲时间执行。51Testing软件测试网:@ n6A%v)Bp


[8x ZarBnu+D;@0

缓存

&kvZ+?uhm!i051Testing软件测试网xLj^7E,gJ"A4JM

硬件/操作系统缓存有时候会使基准测试复杂化。一个简单例子是文件系统缓存,这种缓存可以在硬件或操作系统中发生。如果想对从文件读取字节所花费的时间进行基准测试,但是基准测试代码多次读取同一个文件(或者多次执行相同的基准测试),那么在第一次读取之后 I/O 时间会显著下降。如果希望对随机文件读取进行基准测试,很可能需要确保读取不同的文件,以避免缓存。

X3T s4}g2eq!L b1T0

主内存的 CPU 缓存极其重要,需要特别关注(参见参考资料)。近 20 年来,CPU 的速度呈指数式快速增长,而主内存的增长慢得多,大致是直线式的。为了调和这种差异,现代的 CPU 大量使用了缓存技术(目前现代 CPU 上的大多数晶体管都用于缓存)。适当利用 CPU 缓存的程序可以大大提高性能(大多数实际工作负载只使用了 CPU 理论吞吐量的一小部分)。

n e X[ f0

有许多因素影响程序是否适当地利用 CPU 缓存。例如,现代 JVM 在优化内存访问方面做了大量工作:它们可能重新布置堆空间、把值从堆转移到 CPU 寄存器、执行堆栈分配或执行对象分解(参见参考资料)。但是,一个重要因素是数据集的大小。假设用n表示任务数据集的大小(例如,假设它使用一个数组的长度n)。那么,只涉及单一n值的任何基准测试结果都很不可靠;必须针对各种n值执行一系列基准测试。J. P. Lewis 和 Ulrich Neumann 所写的文章提供了一个出色的示例(参见参考资料)。他们制作了 Java FFT 性能与 C 的对比图,并采用n(在这里是数组大小)的函数形式,由此发现 Java 的性能在比 C 快两倍到慢两倍之间振荡,具体性能取决于选择的n

b&Qq,u`&q6t0

准备

Sf6e.B^lb051Testing软件测试网)I:@"i@U ]M(Wm$\a

开发出基准测试框架并不能一劳永逸地解决基准测试问题。在系统上运行任何基准测试程序之前,还应该解决一些系统问题。51Testing软件测试网5? Jr$x|5Vq6w8L!wx6C[{

电源

Q/?!I:FZct051Testing软件测试网AY/r9_&Bd]{

一个低级硬件问题是,要确保电源管理系统(例如,Advanced Power Management [APM] 或 Advanced Configuration and Power Interface [ACPI])在基准测试期间不进行状态转换,这在笔记本电脑上尤其重要。重大的电源状态变化(比如计算机转入休眠状态)可能不是由于基准测试本身的 CPU 活动导致的,或者很容易探测。但是,其他电源状态变化比较棘手。假设一个基准测试最初出现 CPU 瓶颈,在基准测试期间操作系统决定关闭硬盘驱动器的电源,然后任务在运行的末期希望使用这个硬盘驱动器:在这种情况下,基准测试会完成,但是 I/O 活动可能花费更长时间。另一个例子是,使用 Intel SpeedStep 或相似技术的系统会对 CPU 电源进行节流。在执行基准测试之前,应该通过配置操作系统避免这些问题。51Testing软件测试网(Q1u`8\8xI

其他程序51Testing软件测试网G$bLBu9l Qv9uh@m

h"znLJW}{e0因为基准测试是一个任务,显然不应该同时运行其他程序(除非测试目的是检查您的任务在有负载机器上的表现如何)。应该关闭所有不重要的后台进程,并避免调度的进程(比如屏幕保护和病毒扫描程序)在基准测试期间启动。51Testing软件测试网P^)_L0xanVE

51Testing软件测试网o*tlvfq%J7Z.n T

Windows 提供了ProcessIdleTaskAPI,可以通过它在执行基准测试之前执行所有未完成的空闲进程。可以从命令行执行Rundll32.exe advapi32.dll,ProcessIdleTasks来访问这个 API。注意,它可能要花费几分钟,尤其是在一段时间内没有调用它的情况下。(后续执行常常只需几秒就可以完成)。

#`+k/i3U:R8X0\0

JVM 选项51Testing软件测试网O-E7j Z~

51Testing软件测试网?WS'PS

有许多 JVM 选项会影响基准测试。比较重要的选项包括:

~j'X#A FK0
  • JVM 的类型:服务器(-server)与客户机(-client)。51Testing软件测试网q5nZ;z SF
    51Testing软件测试网;]@9U7_0Jl[%z-DIV
  • 确保有足够的内存可用(-Xmx)。51Testing软件测试网/aq'FF nW)obC1z

    5[O+M)V"u1v0
  • 使用的垃圾收集器类型(高级的 JVM 提供许多调优选项,但是要小心使用)。51Testing软件测试网)_zm'spaV

    E0hG9|0cc"S0
  • 是否允许类垃圾收集(-Xnoclassgc)。默认设置是允许类 GC;使用-Xnoclassgc可能会损害性能。51Testing软件测试网 MaHJ}o#K
    51Testing软件测试网j ]%eEe.n8?
  • 是否执行 escape 分析(-XX:+DoEscapeAnalysis)。51Testing软件测试网$P;g!a a.U%U

    J&v:Y8Xhx)Ns0
  • 是否支持大页面堆(-XX:+UseLargePages)。51Testing软件测试网6D*D1gR Rok].f
    51Testing软件测试网+r-~ I xWk|
  • 是否改变了线程堆栈大小(例如,-Xss128k)。51Testing软件测试网`6Redj
    51Testing软件测试网-i5e~3R3Zr ML
  • 使用 JIT 编译的方式:总是使用(-Xcomp)、从不使用(-Xint)或只对热点使用(-Xmixed;这是默认选项,产生的性能最好)。51Testing软件测试网Y&w'@2n7upj U
    51Testing软件测试网]~ E lmo9Mj
  • 在执行 JIT 编译之前(-XX:CompileThreshold)、后台 JIT 编译期间(-Xbatch)或分级的 JIT 编译期间(-XX:+TieredCompilation)收集的剖析数据量。
    JE9@m"U(g051Testing软件测试网%@OM9hR7wG }
  • 是否执行偏向锁(biased locking,-XX:+UseBiasedLocking);注意,JDK 1.6 及更高版本会自动执行这个特性。51Testing软件测试网_8zd&PQi ?:{O!h

    q%oZrvch*sj0
  • 是否激活最近的试验性性能调整(-XX:+AggressiveOpts)。
    Hs"| e}9D.Z1t051Testing软件测试网\+Ngl,M
  • 启用还是禁用断言(-enableassertions-enablesystemassertions)。51Testing软件测试网 Y"aSHgZ0m [y*Ud
    51Testing软件测试网I4O}9XWp2R2|
  • 启用还是禁用严格的本机调用检查(-Xcheck:jni)。
    *K7h]$B ntx0
    &wg}G g}VX0
  • 为 NUMA 多 CPU 系统启用内存位置优化(-XX:+UseNUMA)。

第 1 部分结束语51Testing软件测试网z[lR)Ucl2h

基准测试是极其困难的。许多因素会影响结果,其中一些因素很微妙。为了获得精确的结果,需要一个全面的解决方案,通过使用基准测试框架可以解决一部分问题。第 2 部分将介绍一个健壮的 Java 基准测试框架。51Testing软件测试网FoSB],R~ H)f!l


9} uZ{ B0

参考资料

K%n_/kP vD051Testing软件测试网T:n,]0k^X P

学习

E:T!g&\"u S051Testing软件测试网w;lLe}

讨论

NeG6B~ x*J K0

关于作者

r_/F8yrL#J0

Brent Boyer 是专业软件开发人员,他从事软件开发已经超过 9 年。他是 Elliptic Group, Inc. 的负责人,这是一家位于纽约的软件开发公司。51Testing软件测试网E$T*{$B)mE


TAG:

 

评分:0

我来说两句

Open Toolbar