|
程序性能一直是受到关注的问题,即使在现在这样的高性能硬件时代,也是如此。本文是分两部分的文章系列的第一篇,讨论与 Java™ 代码基准测试相关的许多问题。第 2 部分 讨论基准测试的统计并提供一个执行 Java 基准测试的框架。因为几乎所有新语言都是基于虚拟机的,所以本文讨论的基本原则适用于许多编程语言。 这个分两部分的文章系列只讨论程序执行时间,不考虑执行程序时的其他重要性质,比如内存使用量。即使在如此狭义的性能定义之下,精确地进行代码基准测试仍然有很多困难。这些问题的数量和复杂性使大多数基准测试都不太精确,常常导致误解。本文的第一部分只讨论这些问题,并给出了在编写自己的基准测试框架时需要考虑的各个方面。 一个性能难题 我首先通过一个性能难题演示一些基准测试方面的问题。请考虑清单 1 中的代码(参见 参考资料 获得本文的完整示例代码链接):
public static void main(String[] args) { int value = 0; long t2 = System.nanoTime(); protected static int calculate(int arg) { global = arg * 6;
保持此代码不变(calculate 中没有 arg 测试) 5 秒 6 年后,我在下面的现代配置上运行了 清单 1 中的代码(除非另外说明,本文中的所有基准测试结果都采用这种配置): 硬件:2.2 GHz Intel Core 2 Duo E4500,2 GB RAM 38.601 ms Click 的幻灯片讨论了为什么会得到奇怪的结果(他把这种现象归因于复杂的 JVM 行为;还牵涉到一个 bug)。Click 是 HotSpot JVM 的架构师,所以他的解释应该是合理的。但是,普通的程序员有办法进行正确的基准测试吗? 答案是肯定的。在本文的 第 2 部分 中,我将提供一个 Java 基准测试框架,您可以放心地下载和使用它,因为它处理了许多基准测试问题。这个框架很容易满足大多数基准测试需求:只要把目标代码打包成特定类型的任务对象(Callable 或 Runnable),然后调用 Benchmark 类。其他所有工作(性能度量、统计数据计算和结果报告)都会自动完成。 为了演示这个框架的使用方法,我把 main 替换为清单 2 中的代码,从而重新对 清单 1 中的代码进行基准测试:
mean = 20.241 ms ... 在这种情况下,使用 Benchmark 获得了预期的结果,这可能是因为它在内部执行 task 许多次,丢弃出现稳定的执行状态之前的 “预热(warmup)” 数据,然后执行一系列精确的度量。与之相反,清单 1 中的代码马上开始度量执行时间,这意味着它的结果与实际代码的执行时间关系不大,但与 JVM 行为 密切相关。尽管在上面的结果中省略了(由 ... 表示),但是 Benchmark 还执行一些有意义的统计计算,这些计算表明了结果的可靠性。 但是,请不要直接使用这个框架。您应该在一定程度上熟悉本文,尤其是熟悉与 动态优化 有关的一些复杂问题,以及 第 2 部分 中讨论的一些解释问题。不要盲目地相信任何数字。要了解这些数字是如何获得的。
执行时间度量 从原理上看,度量代码的执行时间很简单: 记录开始时间。
一个问题是分辨率:System.currentTimeMillis 表示返回的结果具有名义上的毫秒级分辨率(参见 参考资料)。如果假设结果包含随机的 ±1 ms 误差,并希望执行时间的度量误差不超过 1%,那么对于执行时间等于或小于 200 ms 的任务,System.currentTimeMillis 就不能满足分辨率需求(因为两次度量涉及两个误差,误差的和可能达到 2 ms)。 在真实环境中,System.currentTimeMillis 的分辨率可能会糟糕 ~10-100 倍。它的 Javadoc 指出: 注意,尽管返回值的时间单位是毫秒,但是值的粒度取决于底层操作系统,甚至可能比操作系统的时间单位更大。例如,许多操作系统以几十毫秒作为时间度量的单位。
System.currentTimeMillis 的最后一个问题是,假设它反映 “墙上时钟” 的时间,这个问题甚至会影响长时间运行的任务。这意味着,由于标准时间到夏时制的转换或 Network Time Protocol(NTP)同步等事件,它的值偶尔会有突变(向前或向后)。这些调整虽然很少出现,但是可能导致错误的基准测试结果。 JDK 1.5 引入了一个分辨率更高的 API:System.nanoTime(参见 参考资料)。它名义上返回纳秒数,但是有不确定的偏移量。它的关键特性包括: 它只适用于度量时间差。
JDK 1.5 还引入了 ThreadMXBean 接口(参见 参考资料)。它有几个功能,但是它的 getCurrentThreadCpuTime 方法与基准测试的关系尤其密切(参见 参考资料)。这个方法不度量流逝(“墙上时钟”)时间,而是度量当前线程使用的实际 CPU 时间(这个时间小于或等于流逝时间)。 不幸的是,getCurrentThreadCpuTime 也有一些问题: 您的平台可能不支持它。
所有时间度量 API 需要注意的问题:它们都有执行开销,如果过于频繁地执行这些 API,就会严重歪曲度量值。这个问题的影响高度依赖于平台。例如,在 Windows 的现代版本中,System.nanoTime 涉及一个执行时间为微秒级的操作系统调用,所以调用它的频率不应该高于每 100 微秒一次,否则对度量的影响就会超过 1%。(相反,System.currentTimeMillis 只需要读取一个全局变量,所以执行得非常快,是纳秒级的。如果仅仅考虑对度量的影响,可以更频繁地调用它;但是,这个全局变量的更新没这么频繁,根据 表 1 来看,大约是每 10 到 15 毫秒一次,所以频繁地调用它是没有必要的)。另一方面,在大多数 Solaris(和某些 Linux®)机器上,System.nanoTime 常常比 System.currentTimeMillis 执行得快。
代码预热 在 一个性能难题 一节中,我指出 Benchmark 产生更可靠的结果的原因是,它只度量稳定状态下 task 的执行时间,而不理会最初的性能。大多数 Java 实现具有复杂的性能生命周期。一般来说,最初的性能往往相当低,然后性能显著提高(常常出现几次性能跃升),直到到达稳定状态。假设希望度量稳定状态下的性能,就需要了解影响这个过程的所有因素。 类装载 JVM 通常只在类的第一次使用类时装载它们。所以,task 的第一次执行时间包含装载它使用的所有类的时间(如果这些类还没有装载的话)。因为类装载往往涉及磁盘 I/O、解析和检验,这会显著增加 task 的第一次执行时间。常常可以通过多次执行 task 来消除这种影响。(我说常常 —— 而不是总是,这是因为 task 可能具有复杂的分支行为,这可能导致它在任何给定的执行过程中并不使用所有可能用到的类。幸运的是,如果执行任务足够多次,就可能经历所有分支,因此很快就会装载所有相关类)。 如果使用定制的类装载器,就有另一个问题:JVM 可能认为一些类已经成了垃圾,因此决定卸载它。这不太可能严重影响性能,但是仍然会使基准测试结果产生偏差。 可以在基准测试之前和之后调用 ClassLoadingMXBean 的 getTotalLoadedClassCount 和 getUnloadedClassCount 方法,以此判断在基准测试过程中是否发生了类装载/卸载(参见 参考资料)。如果两次的结果不同,就是还未达到稳定状态。 混合模式 在执行即时(Just-in-time,JIT)编译之前,现代的 JVM 通常会运行代码一段时间(常常是纯解释式运行),从而收集剖析信息(参见 参考资料)。这对基准测试的影响在于,任务可能需要执行许多次,才能达到稳定状态。例如,Sun 的客户机/服务器 HotSpot JVM 当前的默认行为是,必须对一个代码块进行 1,500(客户机)或 10,000(服务器)次调用,之后才对包含这个代码块的方法进行 JIT 编译。 因此,对稳定状态下的性能进行基准测试需要以下步骤: 执行 task 一次,以便装载所有类。 步骤 2 比较棘手:怎么能够知道 JVM 什么时候完成了对这个任务的优化? 一种看似聪明的方法是不断度量执行时间,直到结果值收敛。这种方式似乎很好,但是如果 JVM 正在收集剖析信息,然后在您开始步骤 5 之后突然应用剖析信息执行 JIT 编译,这种方法就无效了;这在 未来 更可能引起问题。 另外,如何量化 “收敛” 的概念呢? 连续编译? 目前,Sun 的 HotSpot JVM 只执行一个剖析阶段,然后可能执行编译。如果忽略 去优化,当前并不执行连续编译,这是因为把剖析代码放在热点方法中开销太大了(参见 参考资料)。 对于剖析开销问题,存在一些解决方案。例如,JVM 可以保留方法的两个版本:一个不包含剖析代码的快速版本和执行剖析的慢速版本(参见 参考资料)。JVM 在大多数时候使用快速版本,只是偶尔使用慢速版本来维护剖析信息,这样就不会对性能产生显著影响。JVM 还可以在另一个处理器核空闲时并发执行慢速版本。这样的技术在未来可能使连续编译成为常规做法。 如果可以判断什么时候 JIT 编译,就可以更有把握地确定稳定状态性能。尤其是,如果您认为已经到了稳定状态并开始基准测试,但是随后发现在基准测试期间发生了编译,那么可以中止并重试。 根据我的知识,还没有探测 JIT 编译是否发生的完美方法。最好的技术是在基准测试之前和之后调用 CompilationMXBean.getTotalCompilationTime。不幸的是,CompilationMXBean 的实现非常拙劣,所以这种方法有许多问题。另一种技术是,在使用 -XX:+PrintCompilation JVM 选项的情况下,解析(或人工观察)stdout(参见 参考资料)。
动态优化 除了预热问题之外,JVM 的动态编译涉及另外几个影响基准测试的问题。这些问题很微妙。而且更糟糕的是,只能靠基准测试程序员来解决这些问题,基准测试框架对此没有帮助。(本文的 缓存 和 准备 两节也讨论一些由基准测试程序员负责解决的问题,但是这些问题基本上靠常识就能够解决)。 去优化 另一个问题是去优化(参见 参考资料):编译器可以停止使用已编译的方法,并对它进行一段时间的解释,然后重新编译它。当执行优化的动态编译器做出的假设已经过时时,就会发生这种情况。一个例子是使单态调用转换失效的类装载。另一个例子是不常用的分支:在最初编译一个代码块时,只编译最常用的代码路径,而不常用的分支(比如异常路径)仍然采用解释方式。但是,如果不常用的分支变成了经常执行的,它们就成了热点,这会触发重新编译。 因此,即使按照前一节中的建议实现了稳定状态,也要注意性能仍然可能突然下降。这是需要探测在基准测试期间是否发生 JIT 编译的另一个原因。 堆栈上替换 另一个问题是堆栈上替换(OSR),这种高级 JVM 特性有助于优化某些代码结构(参见 参考资料)。请考虑清单 4 中的代码:
public static void main(String[] args) { int result = 0; long t2 = System.nanoTime();
初看上去,OSR 似乎很不错。好像 JVM 可以处理任何代码结构,同时提供最佳性能。不幸的是,OSR 有一个不太为人所知的缺陷:在使用 OSR 时,代码质量可能是次优的。例如,OSR 有时候无法提升循环、消除数组边界检查或解开循环(参见 参考资料)。如果使用 OSR,可能无法得到最佳性能。 假设希望获得最佳性能,那么解决 OSR 问题的惟一方法是了解什么时候会出现 OSR,并调整代码结构来避免它。这通常需要把关键的内部循环放在单独的方法中。例如,清单 4 中的代码可以改写为清单 5:
int result = 0; long t2 = System.nanoTime(); private static int add(int result) { // method extraction of inner loop 1 private static int xor(int result) { // method extraction of inner loop 2
关于 OSR 的最后一点提示:通常,只有程序员很懒惰,把所有东西都放在一个方法(比如 main)中时,它才会给基准测试带来性能问题。在真实的应用程序中,程序员通常会(而且应该)编写许多细粒度的方法。另外,影响性能的代码通常会长时间运行,并涉及多次调用关键方法。所以,真实的代码通常不会受到 OSR 性能问题的影响。在您的应用程序中,不需要过分担心这个问题,不必为此破坏优雅的代码(除非可以证明它确实造成了损害)。注意,Benchmark 在默认情况下会多次执行任务来收集统计数据,多次执行的副作用是消除 OSR 对性能的影响。 消除死代码 另一个微妙的问题是消除死代码(DCE),参见 参考资料。在某些情况下,编译器可以判断出某些代码根本不影响输出,所以编译器会消除这些代码。清单 6 给出一个静态执行(即在编译时由 javac 执行)死代码消除的典型示例:
private void someMethod() {
我还没有找到出色地描述编译器用来判断死代码的所有条件的文档(参见 参考资料)。不可达代码显然是死代码,但是 JVM 采用的 DCE 策略常常更激进。 例如,请重新考虑 清单 4 中的代码:注意,main 不只计算 result,而且在输出中使用 result。假设进行一个简单修改,从 println 中删除 result。在这种情况下,激进的编译器可能认为它根本不需要计算 result。 这不是一个单纯的理论问题。请考虑清单 7 中的代码:
int result = 0; long t2 = System.nanoTime(); private static int sum() {
要想保证 DCE 不会消除您希望进行基准测试的计算,惟一的方法是让计算生成结果,然后以某种方式使用结果(例如,像清单 7 中的 println 那样在输出中使用)。Benchmark 类支持这种做法。如果任务是 Callable,就要确保 call() 方法返回计算所获得的结果。如果任务是 Runnable,就要确保任务的 toString 方法(这个方法必须覆盖 Object 对象的方法)使用的某个内部状态是用这个计算获得的。如果遵守这些规则,Benchmark 应该会完全防止 DCE。 与 OSR 一样,对于真实的应用程序 DCE 常常不是问题(除非您希望在特定时间内执行代码)。但是与 OSR 不同,DCE 对于编写得很糟糕的基准测试会造成严重问题:OSR 只会使结果不太精确,而 DCE 可能导致完全错误的结果。
资源回收 典型的 JVM 会自动执行两种资源回收:垃圾收集和对象终结(GC/OF)。从程序员的角度来看,GC/OF 几乎是不确定的:它在根本上不受您的控制,可以在 JVM 认为需要的任何时候发生。 在基准测试中,结果应该包含由于任务本身造成的 GC/OF 时间。例如,如果仅仅因为任务的最初执行时间很短,就认为这个任务很快,可能是不可靠的,因为它最终可能产生很大的 GC 时间。(但是注意,一些任务不需要创建对象。相反,它们只需访问已经创建的对象。假设一次基准测试希望度量出访问某个数组元素所用的时间:这个任务应该不用创建数组。相反,应该在其他地方创建数组,这个任务可以使用数组的引用)。 但是,还需要把任务的 GC/OF 与同一 JVM 会话中其他代码造成的 GC/OF 分开。惟一的方法是在执行基准测试之前尝试清理 JVM,还要尝试确保任务本身的 GC/OF 在度量结束前完全完成。 System 类提供了 gc 和 runFinalization 方法,可以用这些方法清理 JVM。但是注意,这些方法的 Javadoc 仅仅声明 “当控制从方法调用返回时,Java 虚拟机会尽可能执行 GC/OF”。 第 2 部分 中提供的 Benchmark 类按照以下步骤处理 GC/OF: 在执行任何度量之前,它调用 cleanJvm 方法,这个方法根据需要多次调用 System.gc 和 System.runFinalization,直到内存使用量稳定下来,并且所有对象已经终结。
long t1 = System.nanoTime();
缓存 硬件/操作系统缓存有时候会使基准测试复杂化。一个简单例子是文件系统缓存,这种缓存可以在硬件或操作系统中发生。如果想对从文件读取字节所花费的时间进行基准测试,但是基准测试代码多次读取同一个文件(或者多次执行相同的基准测试),那么在第一次读取之后 I/O 时间会显著下降。如果希望对随机文件读取进行基准测试,很可能需要确保读取不同的文件,以避免缓存。 主内存的 CPU 缓存极其重要,需要特别关注(参见 参考资料)。近 20 年来,CPU 的速度呈指数式快速增长,而主内存的增长慢得多,大致是直线式的。为了调和这种差异,现代的 CPU 大量使用了缓存技术(目前现代 CPU 上的大多数晶体管都用于缓存)。适当利用 CPU 缓存的程序可以大大提高性能(大多数实际工作负载只使用了 CPU 理论吞吐量的一小部分)。 有许多因素影响程序是否适当地利用 CPU 缓存。例如,现代 JVM 在优化内存访问方面做了大量工作:它们可能重新布置堆空间、把值从堆转移到 CPU 寄存器、执行堆栈分配或执行对象分解(参见 参考资料)。但是,一个重要因素是数据集的大小。假设用 n 表示任务数据集的大小(例如,假设它使用一个数组的长度 n)。那么,只涉及单一 n 值的任何基准测试结果都很不可靠;必须针对各种 n 值执行一系列基准测试。J. P. Lewis 和 Ulrich Neumann 所写的文章提供了一个出色的示例(参见 参考资料)。他们制作了 Java FFT 性能与 C 的对比图,并采用 n(在这里是数组大小)的函数形式,由此发现 Java 的性能在比 C 快两倍到慢两倍之间振荡,具体性能取决于选择的 n。
准备 开发出基准测试框架并不能一劳永逸地解决基准测试问题。在系统上运行任何基准测试程序之前,还应该解决一些系统问题。 电源 一个低级硬件问题是,要确保电源管理系统(例如,Advanced Power Management [APM] 或 Advanced Configuration and Power Interface [ACPI])在基准测试期间不进行状态转换,这在笔记本电脑上尤其重要。重大的电源状态变化(比如计算机转入休眠状态)可能不是由于基准测试本身的 CPU 活动导致的,或者很容易探测。但是,其他电源状态变化比较棘手。假设一个基准测试最初出现 CPU 瓶颈,在基准测试期间操作系统决定关闭硬盘驱动器的电源,然后任务在运行的末期希望使用这个硬盘驱动器:在这种情况下,基准测试会完成,但是 I/O 活动可能花费更长时间。另一个例子是,使用 Intel SpeedStep 或相似技术的系统会对 CPU 电源进行节流。在执行基准测试之前,应该通过配置操作系统避免这些问题。 其他程序 因为基准测试是一个任务,显然不应该同时运行其他程序(除非测试目的是检查您的任务在有负载机器上的表现如何)。应该关闭所有不重要的后台进程,并避免调度的进程(比如屏幕保护和病毒扫描程序)在基准测试期间启动。 Windows 提供了 ProcessIdleTask API,可以通过它在执行基准测试之前执行所有未完成的空闲进程。可以从命令行执行 Rundll32.exe advapi32.dll,ProcessIdleTasks 来访问这个 API。注意,它可能要花费几分钟,尤其是在一段时间内没有调用它的情况下。(后续执行常常只需几秒就可以完成)。 JVM 选项 有许多 JVM 选项会影响基准测试。比较重要的选项包括: JVM 的类型:服务器(-server)与客户机(-client)。
|