解剖一个有缺陷的微基准测试

11 篇文章 0 订阅
6 篇文章 0 订阅

.

原文:《Anatomy of a flawed microbenchmark

 

解剖一个有缺陷的微基准测试

前言

即使“良好的性能”不是一个项目的关键需求,甚至不是需求之一,你也很难忽略性能方面的考虑。因为你可能会认为不考虑性能的程序员不是好工程师。在通往编写高性能代码的过程中,开发人员经常会编写基准测试程序来测量对比不同实现方式的性能。不幸的是,正如《Dynamic compilation and performance measurement》所说,与其它静态编译语言相比,评估 Java 中一段代码或数据结构的性能要困难得多。

 

一个有缺陷的微基准测试

我十月份的文章《More flexible, scalable locking in JDK 5.0》发布后,一位同事给我发来一段基准测试代码 SyncLockTest(见下文“有缺陷的 SyncLockTest 微基准测试”),号称能测量出 synchronized 原语和新的 ReentrantLock 类两者哪个更快。在他的笔记本电脑上运行完这段测试代码后,他得出“synchronized 原语更快”这个与我文章相反的结论,而证据就是他的这次基准测试。然而他的整个基准测试过程中,微基准测试的设计、实现、执行以及对结果数据的解释都存在缺陷。这个同事是个非常聪明的人,他已经在这方面的微基准测试已经有较多经验了。这也反衬出微基准测试真的很难。

 

有缺陷的 SyncLockTest 微基准测试

Java代码

 

  1. interface Incrementer {  

  2.   void increment();  

  3. }  

  4.   

  5. class LockIncrementer implements Incrementer {  

  6.   private long counter = 0;  

  7.   private Lock lock = new ReentrantLock();  

  8.   public void increment() {  

  9.     lock.lock();  

  10.     try {  

  11.       ++counter;  

  12.     } finally {  

  13.       lock.unlock();  

  14.     }  

  15.   }  

  16. }  

  17.   

  18. class SyncIncrementer implements Incrementer {  

  19.   private long counter = 0;  

  20.   public synchronized void increment() {  

  21.     ++counter;  

  22.   }  

  23. }  

  24.    

  25. class SyncLockTest {  

  26.   static long test(Incrementer incr) {  

  27.     long start = System.nanoTime();  

  28.     for(long i = 0; i < 10000000L; i++)  

  29.       incr.increment();  

  30.     return System.nanoTime() - start;  

  31.   }  

  32.    

  33.   public static void main(String[] args) {  

  34.     long synchTime = test(new SyncIncrementer());  

  35.     long lockTime = test(new LockIncrementer());  

  36.     System.out.printf("synchronized: %1$10d\n", synchTime);  

  37.     System.out.printf("Lock:         %1$10d\n", lockTime);  

  38.     System.out.printf("Lock/synchronized = %1$.3f",  

  39.       (double)lockTime/(double)synchTime);  

  40.   }  

  41. }  

SyncLockTest 定义了同一个接口的两种实现,并使用 System.nanoTime() 方法对每种实现的 10,000,000 次调用进行计时。这两种实现都是线程安全的计数器。其中一个使用了内建的同步(synchronized 原语),另一种使用了 ReentrantLock 类。这份基准测试代码的目标是为了回答“synchronized 原语和 ReentrantLock 类哪个更快”。让我们来分析一下为什么这份看上去没有错误的基准测试为什么无法达到它所声称的目标,或者说这份测试到底测了什么。

 

概念上的缺陷

先不谈实现上的缺陷,这份基准测试在概念上就有严重缺陷——它误解了它试图要解决的问题。它试图测量比对(内建)synchronization(synchronized 原语)和 ReentrantLock 的性能开销,也就是两者在协调多线程操作时所用技术的性能开销。但是这份测试代码只有一个线程,所以永远不会有竞争。它从一开始就遗漏了测试与锁有关(需要锁)的场景。

众所周知,在早期的 JVM 实现中,非竞争场景下的(内建)同步(synchronized 原语)比较慢。但是这性能问题现在基本上已得改善。(查看文末相关话题了解JVM优化非竞争场景下内建同步性能所使用的技术。)另一方面,竞争场景下(内建)同步的开销仍然比非竞争场景下要大得多。当发生锁竞争时,JVM 需要维护一个等待线程的队列,此外还需调用操作系统的方法对那些未能立即拿到锁的线程进行阻塞和解除阻塞操作。此外,处于高度竞争的应用往往伴随着低吞吐量,因为线程调度花费的时间更多,实际处理业务的时间少了,而且当线程被阻塞等待锁时可能导致 CPU 处于空闲状态。测试(内建)同步(synchronized 原语)的基准测试必须考虑到现实情况中的竞争程度。

 

方法上的缺陷

该基准测试设计在执行方面至少存在两处错误:

  • 它运行在单处理器系统中(注:是单线程模式)

    • 这并不是高并发程序运行的常规环境。在这样的环境中,同步操作的性能表现会和多处理器系统环境有本质上的不同。

  • 它只在一个平台上做过测试

当测试一个原语(尤其是与底层硬件交互如此重要)的性能时,在得出结论前,有必要在更多不同平台上测试。当测试并发这类复杂的场景时,建议在十个以上不同测试系统,不同处理器和不同数量的处理器场景中测试后,再得出总体的性能概论。(内存配置和处理器代次也是需要考虑的。)

 

实现上的缺陷

在实现方面,SyncLockTest 忽视了许多动态编译相关的特性。正如你在《Dynamic compilation and performance measurement》所看到的,HopSpot JVM 会先以解释模式执行一段代码(注:以方法为单位),只有当执行次数达到一定数量(阀值)时才会将其编译成机器码。如果没有正确对JVM进行预热,会在两方面严重影响性能测量。首先,JIT分析与编译代码的耗时被包含到了测试的运行时间内。更重要的是,如果编译发生在测试(正式测试代码)运行中,你的测试结果(耗时)将是部分解释运行的耗时的和,加上JIT编译耗时,加上优化后执行的耗时。这样的结果对于被测代码的真实性能没有多少有用的信息。另一方面,如果在运行(正式)测试前,代码没有被编译(编译成机器码)过,且在测试运行期间没有发生编译,那么你的整个测试过程都是解释执行的。这样的结果也无法给你多少对于被测代码在真实世界中性能的有用信息。

SyncLockTest 也受到了《Dynamic compilation and performance measurement》中所讨论的内联和反优化问题的影响。在那篇文章中,第一份耗时测量代码以单态调用转化的方式被激进地内联(优化)了。(单态调用转换是指针对虚方法的调用被转换为对目标方法的直接调用。Java中的方法默认是虚方法,是一种面向对象设计中的多态特性)第二份代码则因为随后JVM载入另一个继承自同一基类或接口的类而被“反优化”。当耗时测试方法来自一个 SyncIncrementer 实例时,(JVM)运行时识别出只有一个实现了 Incrementer 接口的类被载入,所以会将针对虚方法 increment() 的调用转换为对 SyncIncrementer 实例方法的直接调用。随后,当耗时测试方法来自一个 LockIncrementer 实例时,test() 方法被当作虚方法重新编译。这意味着第二份 test() 方法耗时测试中的每个迭代比第一份做了更多的工作。这好比我们是在比较苹果与橘子的区别。这会严重扭曲测试结果,导致无论哪种实现,第一个先运行的方案会显得更快。

 

基准测试代码看上去不像真实的代码

到目前为止所讨论的基准测试代码缺陷可以通过合理的返工修复。如,引入类似“竞争程度”这样的测试参数,并且在大量不同的系统上、不同测试参数值条件下测试。但是还有一些缺陷可能无法通过任何调整来解决。为了了解为什么会这样,你需要像JVM一样思考,并了解当 SyncLockTest 被编译时会发生什么。

Heisenbenchmark 原则

当编写一个微基准测试来测量像 synchronization 的(编程)语言原语性能时,你就是在和 Heisenberg 原则作斗争。你想要测量操作X有多快,所以你不想做除了X之外的任何其它操作。但是通常得到一个什么也不做的基准测试。在这样的基准测试中,编译器会在你没有意识到的情况下执行部分或全部优化,导致测试运行得比期望快。如果你将额外的代码Y加入到你的基准测试中,你测量的就是X+Y的性能,也就是对X的测量中中引入了“噪声”。更糟糕的是Y的存在改变了JIT优化X的行为。编写一个良好的微基准测试意味着在“(额外)操作与数据流依赖不足以阻止编译器优化你的整个测试程序”和“(额外)操作太过多导致你想测量的东西被‘噪声’淹没”之间找到一个难以捉摸的平衡。

因为运行时编译使用 profiling 数据来指导它的优化操作,JIT可能会更好地优化测试代码,而不同于真实代码。与所有基准测试一样,编译器有能力优化整个测试代码是一项重要的风险。因为它将意识到基准测试代码没有做任何事情,或者所产出的结果没有被用于任何操作。编写有效的基准测试需要让编译器变“愚”,使它不会去除这些“无效”代码(即使真的是无效的代码)。两个 Incrementer 类中计数变量的使用方式没能使编译器变“愚”。编译器在消除无效代码方面通常比我们认为的更聪明。

事实上这个问题与 synchronization 是语言内建特性复合在一起。JIT编译器被允许在处理同步代码块时有一些自由,来降低性能开销。在有些情况下,同步操作可以被完全移除,而且相邻同步代码块对同一个同步元(monitor)的同步操作可以被合并。如果我们在测量同步的开销,这些优化真的会打击到我们。因为我们不知道有多少(这个案例中几乎是所有)同步操作被优化掉了。更糟的是,JIT优化 SyncTest.increment() 中什么也没做的代码的方式与真实世界中的程序是非常不同的。

更糟的来了。这个微基准测试的表面目的是测试 synchronization(synchronized 原语) 和 ReentrantLock 哪个更快。因为 synchronization 是语言内建的,而 ReentrantLock 是一个普通的 Java 类,所以编译器对“什么也没做的 synchronization”和“获取 ReentrantLock”的优化是不同的。这个优化使得什么也没做的 synchronization 看起来更快。因为编译器对这两个测试案例的优化是不同的,在真实世界中场景中的优化方式也是不同的,所以这份测试程序的执行结果几乎不能能告诉我们两者(synchronization 和 ReentrantLock)在真实世界场景中的性能差异。

 

无效代码消除

在《Dynamic compilation and performance measurement》中,我论述了基准测试中无效代码消除的问题。这是因为基准通常不会对计算结果做任何处理,导致编译器通常可以基准测试中的整块代码,进而扭曲耗时测量。这份测试代码多个地方有该问题。事实上,编译器的无效代码消除对我们(的程序)不一定是致命的。但在该案例中,这个问题在两个代码执行路径上能导致不同程度的优化,系统性地扭曲我们的测量。

两个 Incrementer 类旨在做一些什么也不做的工作(对一个变量做递增)。但是一个聪明的JVM会察觉到这两个计数变量从来都不会被读取,因此可以消除相关代码(包括递增操作代码)。这就是我们存在严重问题的地方——现在 SyncIncrementer.increment() 中的 synchronized 代码块是空的,编译器可以将它完整移除,而 LockIncrementer.increment() 中仍然有锁相关的代码,编译器可能无法完全消除。你可能会认为这(就)是 synchronization 的一种优势,即,编译器能更容易地消除它。但是这种现象更多得是出现在什么也没做的基准测试代码中,而不是真实世界中编写良好的代码。(也就是说真实应用场景中,我们几乎不会写这些什么没做的代码)

问题就是,编译器优化会更好地优化其中一种实现方式,但是这个差异只存在于什么也没做的基准测试中。这导致这种比较 synchronization 和 ReentrantLock 性能的方法会如此之难。

 

循环展开与锁合并

即使编译器没有消除对计数变量的操作,它依然会以不同的方式优化两个 increment() 方法。一种标准的优化是 循环展开。编译器会展开循环代码,从而减少代码分支数量。被展开的迭代数量取决于循环体内代码的数量。很明显,LockIncrementer.increment() 方法中循环体内的代码比 SyncIncrementer.increment() 方法中的多。进一步说,当 SyncIncrementer.incrementer() 被展开且方法调用被内联,这个被展开的循环就是一个“加锁——增值——解锁”操作组合的序列。因为所有这些加锁操作都是针对同一个同步元(monitor),编译器可以执行锁合并(lock coalescing 或 lock coarsening)来合并相邻的同步代码块。这意味着 SyncIncrementer 执行的同步操作比期望的更少。(更糟的是,加锁操作被合并后,同步体内将只包含一个“增值”操作序列,其消耗仅相当于单次加法操作。而且,如果该操作被重复应用,整个循环将被折叠为单个同步块,内部操作就是单个“counter=10000000”操作。真实世界中的JVM真的能执行这些优化。)

再次说明,问题不仅仅是(编译器)优化器会优化我们的基准测试,而是它对其中一种实现方式所做的优化程度与另一种实现方式不同,且对任何一种实现方式所采用的优化方法与真实世界中的不一样。

 

缺陷计分卡(清单)

这些是此基准测试未达到其创建者目的的原因(这是不详尽的清单):

  • 没有执行预热,而且没有考虑JIT执行所花的时间

  • 此测试容易受到单态调用转换和随后的反优化影响

  • synchronized 块和 ReentrantLock 所包含的代码块是无效代码,这会扭曲JIT优化它们的方式;它可以消除整个 synchronization 测试

  • 该测试程序想测量锁的性能,但是它没有包含(多线程)竞争的影响,而且它只在一个单处理系统上运行

  • 该测试程序没有在足够多的不同平台上运行

  • 编译器能在 synchronization 上执行的优化比 ReentrantLock 上更多,但是这些优化方式并不会对真实世界中使用 synchronization 的程序有多大帮助

问错误的问题,得到错误的答案

微基准测试的可怕之处是它们总是产出一个数字,即使这个数字是毫无意义的。它们确实测量一些东西,只是我们不知道到底是什么。通常,它们只测量特定微基准测试的性能,不做其它事。但是很容易说服你自己你的基准测试测量的是某个特定的(数据)结构,并错误地得出对那个(数据)结构的性能结论。

即使当你编写了一个优秀的基准测试,你的测量结果也可能只在你执行的系统上有效。如果你在一个单处理器且内存较小的笔记本系统上运行,你可能无法得出任何关于它在服务器系统上的性能结论。底层并发原语(如 compare-and-swap)的性能在不同硬件架构系统上的性能表现会有相当大的不同。

事实时,试图通过单个数字来测量类似“synchronization 性能”这样的目标是不可能的。synchronized 的性能在不同JVM、处理器、工作负荷、JIT活动、处理器数量、被同步代码数量等条件下都会不同。你能做得最好的就是在一系列不同的平台上运行一系列基准测试,并寻找结果中的相似性。只有那时你才可以开始对 synchronization 的性能下结论。

在 JSR 166(java.util.concurrent)测试过程的基准测试中,不同平台上的性能曲线的形状非常不同。硬件结构操作(construct)(如,CAS)的性能在不同平台和不同处理器数量的场景中表现不同(如,在单处理器系统中,CAS永远不会失败)。内存屏障的性能在单个 Intel P4 超线程(一个芯片两个处理器核心)场景下比两个 P4 要快。而且这个两种场景下的性能都不同于与 Sparc 处理器。所以你能做得最好的就是尝试构建“典型”的(基准测试)样例并在“典型”的硬件环境中测试,并期望这会产出一些与我们的真实程序在真实硬件上性能表现相关的“领悟”。“典型”(基准测试)的样例有哪个些构成要素?一个融合了计算、IO、同步、竞争、内存局部性、分配行为、上下文切换、系统调用、线程间通信的基准测试才会近似与真实世界的应用。也就是说,现实的基准测试看上去非常像一个真实世界的程序。

 

如何编写一个完美的微基准测试

所以,你如何才能写出一个完美的微基准测试呢?首先,编写一个优化良好的JIT。与那些已经编写其它优化良好的JIT的人见面(他们很容易找,因为没有优化良好的JIT不多)。邀请他们吃晚餐,并交换关于如何使Java字节码运行更快的性能伎俩故事。阅读几百篇关于优化 Java 代码执行的论文,并且写几篇。那时候你将拥有编写测量某种东西开销的良好准测试的能力,像同步、对象池、虚方法调用。

 

你在开玩笑吗?

你可能会认为上述编写良好微基准测试的秘诀过于保守。但是编写良好的微基准测试确实需要动态编译、优化和 JVM 实现技术方面的大量知识。为了编写一个真的会测试你所想测试对象的测试程序,你不得不理解“编译器将会做何操作”、“动态编译所得代码的性能特征”、“测试代码与典型的真实世界代码在使用相同(数据)结构上有何不同”。没有这个了解程度,你将无法得知你的测试程序是否测量了你所想要的东西。

 

所以你该做什么?

如果你真的想知道 synchronization 是否比替代的锁机制快(或者其它类似的微性能问题),你该做什么?一个观点是“相信专家”(这多大多数开发者来说都不太合适)。在 ReentrantLock 类的开发中 JSR 166 EG 的成员在很多不同平台上运行了即使没有上千也有几百小时的性能测试,检查了JIT编译出来的机器码,仔细钻研了测试结果。然后他们微调了代码重来。在开发和研究这些类时运用了大量关于JIT和微处理器行为的经验与详细理解。但还是很不幸地无法基于单个基准测试程序结果得出总结,尽管我们非常希望能够得出结论。另一个观点是把你的注意力集中于“宏观”的基准测试。即,编写一些真实世界的程序,用两种方式都实现一遍,开发一套现实的负载生成策略,并分别测量你的程序在现实的负载条件和现实的部署配置下两种不同实现方式的性能。这是大量的工作,但是它将会使你更接近于你要寻找的答案。

 

相关话题

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值