Java理论和实践:一个有缺陷的微基准的剖析

即使性能不是当前项目的一个关键需求,甚至没有被标明为一个需求,通常也难于忽略性能问题,因为您可能会认为忽略性能问题将使自己成为“差劲的工程师”。开发人员在以编写高性能代码为目标的时候,常常会编写小的基准程序来度量一种方法相对于另一种方法的性能。不幸的是,正如您在 December 撰写的 “动态编译与性能测量” 这期文章中所看到的,与其他静态编译的语言相比,评论用 Java 语言编写的给定惯用法(idiom)或结构体的性能要困难得多。

一个有缺陷的微基准

在我发表了十月份的文章 “JDK 5.0 中更灵活、更具可伸缩性的锁定机制” 之后,一个同事给我发了 SyncLockTest 基准(如清单 1 所示),据说用它可以判断 synchronized 与新的 ReentrantLock 类哪一个“更快”。他在自己的手提电脑上运行了该基准之后,作出了与那篇文章不同的结论,说同步要更快些,并且给出了他的基准作为“证据”。整个过程 —— 微基准的设计、实现、执行和对结果的解释 —— 在很多方面都存在缺陷。其实我这个同事是个很聪明的家伙,并且对这个基准也花了不少功夫,可见这种事有多难。

清单 1. 有缺陷的 SyncLockTest 微基准

interface Incrementer {  void increment();}class LockIncrementer implements Incrementer {  private long counter = 0;  private Lock lock = new ReentrantLock();  public void increment() {   lock.lock();   try {    ++counter;   } finally {    lock.unlock();   }  }}class SyncIncrementer implements Incrementer {  private long counter = 0;  public synchronized void increment() {   ++counter;  }}class SyncLockTest {  static long test(Incrementer incr) {   long start = System.nanoTime();   for(long i = 0; i < 10000000L; i++)    incr.increment();   return System.nanoTime() - start;  }  public static void main(String[] args) {   long synchTime = test(new SyncIncrementer());   long lockTime = test(new LockIncrementer());   System.out.printf("synchronized: %1$10d/n", synchTime);   System.out.printf("Lock:     %1$10d/n", lockTime);   System.out.printf("Lock/synchronized = %1$.3f",    (double)lockTime/(double)synchTime);  }}

SyncLockTest 定义了一个接口的两种实现,并使用 System.nanoTime() 来计算每种实现运行 10,000,000 次的时间。在保证线程安全的情况下,每种实现增加一个计数器;其中一种实现使用内建的同步,而另一种实现则使用新的 ReentrantLock 类。此举的目的是回答以下问题:“哪一个更快,同步还是 ReentrantLock?”让我们看看为什么这个表面上没有问题的基准最终没能成功地度量出想要度量的东西,甚至没有度量出任何有用的东西。

构想上的缺陷

暂时先不谈实现上的缺陷, SyncLockTest 首先从构想上就存在缺陷 —— 它误解了它要回答的问题。这个基准的目的是要度量同步和 ReentrantLock 的性能代价,它们是用于协调多个线程的行为的不同技术。然而,该测试程序只包含一个线程,因而显然不存在竞争。它没有首先测试那些真正与锁相关的场景!

在早期的 JVM 实现中,无竞争的同步比较慢,这是众所周知的。然而,从那以后无竞争的同步的性能从本质上已经有了很大的提高。(请参阅参考资料中列出的描述 JVM 用来优化无竞争同步性能的一些技术的文章)。另一方面,有竞争的同步比起无竞争同步来仍然要慢得多。当一个锁处于争用状态下时,JVM 不但要维护一个等待线程队列,而且还必须使用系统调用来阻塞和消除阻塞不能立即得到锁的线程。而且,在高度竞争环境下的应用程序表现出来的吞吐量通常会更低,这不仅是因为花在调度线程上的时间更多了,花在做实际工作上的时间更少了,而且当线程为了等待某一个锁而被阻塞时,CPU 可能处于空闲状态。用来度量同步性能的基准应该考虑实际的竞争程度。

方法上的缺陷

除了设计上的失败,在执行方面至少也有两大败笔 —— 它只在单处理器系统(对于高并发性程序来说,这是一种不寻常的系统,其同步性能与在多处理器系统上可能有本质上的差别)上,并且只在一个平台上执行。在测试一个给定的原语或惯用语的时候,特别是与底层硬件交互很多的原语或惯用语时,在得出关于性能方面的结论之前,需要在很多平台运行基准。当测试像并发这样复杂的东西时,为了得到给定惯用语的总体性能情况,建议采用十来种不同的系统,应用多个处理器(更不用说内存配置和处理器的代数(generation)了)。

实现上的缺陷

至于实现方面,SyncLockTest 忽略了动态编译的很多方面。在12 月份的文章中可以看到,HotSpot JVM 首先以解释的方式执行代码路径,然后在经过一定量的执行后,才将其编译成机器代码。如果没有让 JVM 适当地“热身”,那么 JVM 可能在两个方面导致性能度量上的偏差。首先,测试的运行时间当中包含了 JIT 用于分析和编译代码路径所花的时间。最重要的是,如果编译是在测试运行的过程当中进行的,那么测试结果就变成一定量的解释执行,加上 JIT 编译时间,再加上一定量的优化执行的总时间和,这些并不能让您清楚代码的真正性能。而且,如果在运行测试之前代码没有经过编译,在测试的过程当中也没有进行编译,那么整个测试运行都需要解释,这样就不能体现所要测试的惯用语的真正性能。

SyncLockTest 还沦为在12 月份的文章中所讨论的内联(inlining)和反优化(deoptimization)问题的牺牲品,在这些篇文章中,第一个计时度量的是那些已经与单一调用转换(monomorphic call transformation)内联的代码,而第二个计时所度量的代码,由于 JVM 要装载另一个扩展相同基类或接口的类,因而经过了反优化。当使用 SyncIncrementer 的一个实例来调用计时测试方法时,运行库将认为只装载了一个实现 Incrementer 的类,并且会把对 increment() 的虚方法调用转换为对 SyncIncrementer 的调用。然后,当使用 LockIncrementer 的一个实例调用计时测试方法时,test() 将被重新编译成使用虚方法调用,这意味着与第一个计时相比,通过 test() 来管理方法的第二个计时在每次迭代中要做更多的工作,就好像把测试变成了苹果与橙子之间的比较。这样做会严重扭曲结果,致使无论哪种基准首先执行,看起来都会更快些。

基准代码看上去并不像实际中的代码

通过合理地重写代码,引入一些测试参数(例如竞争程度),并在更多类型的系统中、给测试参数赋予多种不同的值来运行代码,前面所讨论的那些缺陷是可以更正的。但是,对于方法上的一些缺陷,不管如何挽回,都是无法解决的。如果想知道为什么,就应该像 JVM 那样去思考,理解在编译 SyncLockTest 的时候会发生哪些情况。

Heisenbenchmark 原则

编写用于度量一个语言原语(例如同步)的性能的微基准的过程实际上是与 Heisenberg 原则作斗争的过程。您想要度量操作 X 有多快,所以除了 X 外您不想做其他任何事。但是,这样做得到的往往是一个不做任何事的基准,在您不知情的情况下,编译器可能将此操作部分地或者完全地优化掉,使得测试运行起来比预期更快。如果在基准中加入无关的代码 Y,那么现在度量的就是 X+Y 的性能,更糟糕的是,由于 Y 的存在,现在 JIT 优化 X 的方式又发生了变化。如果没有足够的额外填充物和数据流依赖,编译器可能会将整个程序优化至无形,但是如果填充物太多,那么真正需要度量的东西又会迷失在噪音当中,因此要编写一个良好的微基准,就意味着要抓住二者之间微妙的平衡。

因为运行时编译使用概要数据来指导优化,所以 JIT 对测试代码的优化可能不同于对实际代码的优化。对于所有的基准,都存在这样一个很大的风险,即编译器能够优化掉整个基准,因为它将(正确地)认识到基准代码实际上没有做任何事情,或者没有产生任何有用的结果。在编写有效的基准时,要求我们能够“愚弄”编译器,即使它认识到代码没有用处,也不能让它将代码砍掉。在 Incrementer 类中使用计数器变量骗不到编译器,在删除无用代码方面我们对编译器给予了信任,但编译器比我们想象的还要聪明。

此外,还有一个问题是,同步是一种内建的语言特性。JIT 编译器可以随意变动同步锁,以减少它们的性能成本。在某些情况下,同步可能被完全消除,并且在同一个监视器上,同步的邻近同步锁可能被合并。如果我们要度量同步的成本,这些优化实际上害了我们,因为我们不知道有多少同步会被优化掉(在这个例子中,很可能是全军覆没!)。更糟糕的是,JIT 对于 SyncTest.increment() 中不做事的代码的优化与对实际中的程序的优化在方式上有很大的不同。

更糟的还在后面。这个微基准表面上的目的是测试同步与 ReentrantLock 哪个更快。由于同步是内建在语言中的,而 ReentrantLock 是一个普通的 Java 类,编译器对于不做事的同步的优化与对于不做事的 ReentrantLock 的优化在方式上又有不同。这样的优化会使不做事的同步看上去更快些。编译器对此二者的优化方式存在差别,加上对基准和对实际代码的优化方式也是不相同的,因此程序的结果几乎无法告诉我们实际情况下两者在性能上存在的差别。

无用代码的消除

在12 月份的文章中,我讨论了基准中无用代码的消除问题 —— 由于基准常常不做有用的事,因此编译器可能会整块地砍掉基准代码,从而歪曲了对执行时间的度量。基准在很多方面都存在这样的问题。虽然编译器消除无用代码这件事对我们要做的事还不一定会造成致命打击,但这里的问题是,编译器对于两种代码路径可以执行不同程度的优化,这从根本上歪曲了我们的度量。

两个 Incrementer 类的用途是做一些无用的工作(让一个变量递增)。但聪明的 JVM 会发现,这两个计数器变量从来没有被访问过,因此可以消除与使这些变量递增有关的代码。正是这里存在一个严重问题 —— 现在 SyncIncrementer.increment() 方法中的 synchronized 块是空的,编译器可以整个地删除它,而 LockIncrementer.increment() 却仍然包含锁代码,编译器可能会将其完全删除,也可能不会这样做。您可能会想,这部分代码有利于同步 —— 编译器更可能会删除这部分代码 —— 但这样的事情只有在不做事的基准中才如此普遍,而在精心编写的实际代码中就少见得多。

编译器对某种实现比对另一种实现要优化得多一些,但是这种差别只在不做事的基准中才会体现出来,这个问题导致比较同步和 ReentrantLock 的性能是如此之困难。

循环展开和锁合并

即使编译器不消除计数器管理,它也仍会以不同的方式优化两个 increment() 方法。标准的优化是循环展开;编译器将展开循环,以减少分支的数量。展开多少次迭代取决于循环体中有多少代码,而 LockIncrementer.increment() 的循环体中的代码比 SyncIncrementer.increment() 的循环体中的代码“更多”。而且,当展开 SyncIncrementer.increment() 并内联该方法调用时,已展开循环的顺序将是“锁-递增-解锁”这样的顺序。由于这些都是同一个监视器上的锁,因此编译器可以执行锁合并(也叫锁粗化),将邻近的 synchronized 块合并,这意味着 SyncIncrementer 执行的同步将比预期的还要少。(更糟糕的还在后面;在合并锁之后,同步的代码块中只包含一个递增序列,因而可以降低强度,转换成一个单独的相加。而且,如果重复应用这个过程,整个循环将缩水成一个单独的同步块,这个同步块中只有一个 “counter=10000000” 操作。的确,现实中的 JVM 是可以执行这些优化的。)

同样,严格来说,问题并不在于优化器会优化掉我们的基准,而是优化器对于不同的基准会采用不同程度的优化,并且它对于每种基准所应用的优化在实际代码中很可能根本不适用。

有缺陷的评价标准

这里说得不够详尽,但是对于为什么这个基准没有像其作者期望的那样这个问题,这里给出了一些原因:

没有进行热身(warmup),没有考虑 JIT 执行所花的时间。

测试容易受到由单一调用转换引起的错误以及随后的反优化的影响。

受同步块或 ReentrantLock 保护的代码实际上是无用的,这扭曲了 JIT 优化代码的方式。编译器可能可以消除整个同步测试。

测试程序想要度量一个锁原语的性能,但是它在这样做的时候,没有考虑到竞争的影响,并且只是在一个单处理器系统上进行测试的。

没有在足够多类型的平台上运行测试程序。

编译器对同步测试的优化比对 ReentrantLock 测试的优化要更多一些,但是这种优化又不适用于现实当中使用同步的程序。

错误的问题,错误的答案

关于微基准,令人恐慌的事情是它总是产生一个数字,即使这个数字毫无意义。这些基准在度量某个事物,但我们又不确定这个事物到底是什么。通常,它们只度量特定微基准的性能,别无它物。但是您很容易误认为您的基准在度量一个特定结构体的性能,并错误地对结构体的性能下结论。

即使您编写了一个很好的基准,得到的结果可能也只是在运行基准的系统上才有效。如果在一个内存不足的单处理器手提电脑系统上进行测试,那么您恐怕不能对一个服务器系统上的性能下任何结论。至于低级硬件并发原语的性能,不同的硬件体系结构之间更是千差万别。

实际上,企图单凭一个数字来度量“同步性能”之类的东西是不可能的。同步性能会随着 JVM、处理器、工作负载、JIT 活动、处理器数量以及正同步执行的代码的数量和特征而变化。您最好是在一系列不同的平台上运行一系列的基准,然后寻找结果中的相似之处。只有这样,您才可以对同步的性能下结论。

在 JSR 166 (java.util.concurrent) 测试过程的基准运行中,性能曲线的形状随平台的不同而不同。硬件结构体(例如 CAS)的成本随平台和处理器数量的不同而不同(例如,单处理器系统不存在 CAS 调用)。一个超线程(一个模具上有两个处理器核心)Intel P4 的内存壁垒性能(memory barrier performance)要快于两个 P4,而两者的性能特征又不同于 Sparc。因此,您最好是尝试建立一些“典型”例子,然后将它们放在“典型”硬件上运行,并希望这样能在一定程度上揭示现实中的程序在现实中平台上的性能。那么,用什么构成一个“典型”例子呢?它的计算、IO、同步和竞争,它的内存局部性、分配行为、上下文切换、系统调用以及线程间通信都必须与现实当中的应用程序近似。也就是说,一个逼真的基准看上去非常像现实中的程序。

如何编写好的微基准

那么,如何编写好的微基准呢?首先,编写一个好的优化 JIT。跟那些写过其他好的优化 JIT 的人谈谈(这样的人不难找,因为好的优化 JIT 并不多!)。邀请他们会餐,与他们交流有关如何尽可能快地运行 Java 字节码的性能技巧的故事。阅读上百篇关于优化 Java 代码执行的文章,自己也写一些文章。然后您就会拥有编写一个好的度量某种东西的微基准所需的技术,例如同步、对象池或者虚方法调用的成本。

是不是开玩笑?

您可能会想,前面所说的用于编写好的微基准的秘诀过于保守,但编写一个良好的微基准的确需要知道大量有关动态编译、优化和 JVM 实现技术的知识。为了编写一个真正能够测试您所想要测试的东西的测试程序,您必须理解编译器会对这个测试程序做什么,动态编译后的代码的性能特征,以及生成的代码与通常的现实当中使用相同结构体的代码有何不同。没有理解到这个程度,就不能判断您的程序是否能度量您想要度量的东西。

那么您应该怎么做呢?

如果您真的想知道是同步更快还是锁机制更快(或者回答任何类似的微性能问题),那么应该怎么做呢?一种选择(对于大多数开发人员并不适合)是“信任专家”。在 ReentrantLock 类的开发当中,JSR 166 EG 成员在很多不同平台上运行成百上千个小时的性能测试,检查 JIT 生成的机器代码,并用心阅读结果。然后,他们修改代码,再重新测试。在开发和分析这些类的过程中,涉及到大量的专业知识以及对 JIT 和微处理器行为的深度理解,不幸的是,凭一个基准程序的结果就下结论仍然过早,虽然我们也想这样。另一种选择是,将注意力放在“微”基准上 —— 编写一些实际的程序,用两种方法编写代码,开发一种逼真的负载生成策略,并在逼真的负载条件下和逼真的部署配置中使用这两种方法来度量应用程序的性能。这样做工作量会很大,但惟有如此才能更接近您想要的答案。

己欲立先立人,已欲达先达人。

Java理论和实践:一个有缺陷的微基准的剖析

相关文章:

你感兴趣的文章:

标签云: