jmh微基准测试_有缺陷的微基准的剖析

即使性能不是您正在从事的项目的关键要求,甚至不是明确的要求,也常常很难忽略性能问题,因为您可能会认为这会使您成为“坏工程师”。 在编写高性能代码的最后,开发人员经常编写小型基准程序,以衡量一种方法相对于另一种方法的相对性能。 不幸的是,正如您在12月期的“ 动态编译和性能管理 ”中了解到的那样,用Java语言评估给定习惯用法或构造的性能比使用其他静态编译语言要困难得多。

微基准测试有缺陷

在我10月的文章“ 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可能保持空闲。 衡量同步原语性能的基准应该考虑实际的争用程度。

方法论上的缺陷

这种设计失败至少由两次执行失败而加重-仅在单处理器系统上运行(对于高度并发的程序,这是不寻常的环境,并且其同步性能可能与多处理器系统有很大不同),并且仅在一个平台。 在测试给定原语或习惯用法的性能时,尤其是在与底层硬件进行如此显着程度的交互时,在得出有关其性能的结论之前,有必要在许多平台上运行基准测试。 当测试诸如并发之类的复杂事物时,建议使用十二个不同的测试系统,这些系统跨越多个处理器,并且要处理一系列的处理器(更不用说内存配置和处理器的代数了),以开始了解一个处理器的整体性能。习语

实施中的缺陷

关于实现, SyncLockTest忽略了动态编译的许多方面。 如您在12月期中所见,HotSpot JVM首先以解释模式执行代码路径,并且仅在执行一定量后才将其编译为机器代码。 无法正确地“热身” JVM可能会导致两种方式的性能评估发生重大偏差。 首先,JIT分析和编译代码路径所花费的时间包含在测试的运行时中。 更重要的是,如果编译在测试运行的中间进行,则测试结果将是一些解释执行的总和,JIT编译时间加上一些优化的执行的总和,而这并不能给您太多有用的见解进入代码的实际性能。 而且,如果在运行测试之前未编译代码,并且在测试期间未编译代码,则将解释整个测试运行,这也不会使您对惯用语的真实性能有更多了解正在测试。

SyncLockTest也是12月份讨论的内联和非优化问题的受害者,在该问题中,第一个计时通道测量了已经通过单态调用转换积极内联的代码,第二个计时通道测量了由于JVM加载了另一个类而随后被优化的代码。扩展相同的基类或接口。 当使用SyncIncrementer的实例调用计时测试方法时,运行时将识别出只有一个实现Incrementer类已加载,并将虚拟方法调用的increment()转换为对SyncIncrementer直接方法的调用。 然后,在使用LockIncrementer实例运行时序测试方法时,将重新编译test()以使用虚拟方法调用,这意味着通过test()线束方法的第二遍将在每次迭代上进行比第一遍更多的工作,将我们的测试变成了苹果和橘子之间的比较。 这会严重扭曲结果,使第一个运行的替代方案看起来更快。

基准代码看起来不像真实代码

到目前为止,所讨论的缺陷可以通过对基准代码进行合理的修改,引入一些测试参数(例如竞争程度)并在各种系统上运行以及针对多个测试参数值来解决。 但是它也遭受一些方法上的缺陷,这些缺陷可能无法通过任何调整来解决。 要了解为什么会这样,您需要像JVM一样思考,并了解在编译SyncLockTest时会发生什么。

海森基准原则

当编写一个微基准来测量诸如同步之类的语言基元的性能时,您正在努力应对海森堡原理。 您想要测量X的运行速度,所以除了X之外,您不想做其他任何事情。但是,结果常常是什么都不做的基准,编译器无需您意识到就可以部分或完全优化它,从而测试运行速度超出预期。 如果将多余的代码Y放入基准测试中,那么您现在正在测量X + Y的性能,在X的测量中引入噪声,更糟糕的是,Y的存在会改变JIT优化X的方式。编写良好的微基准测试意味着在没有足够的填充物和数据流依赖关系之间找到难以捉摸的平衡,以防止编译器优化整个程序,而填充物太多,以至于您试图测量的内容都被噪声所损失。

因为运行时编译使用概要分析数据来指导其优化,所以JIT可能会以与实际代码不同的方式优化测试代码。 与所有基准测试一样,存在很大的风险,即编译器将能够优化整个过程,因为编译器将(正确地)意识到基准测试代码实际上并未执行任何操作或产生可用于任何操作的结果。 编写有效的基准测试要求我们“愚弄”编译器,以使代码不会被删除,即使确实如此。 在Incrementer类中使用计数器变量是愚弄编译器的失败尝试,但是在消除死代码时,编译器通常比我们认为它们聪明,这比编译器聪明。

同步是内置的语言功能,这使问题更加复杂。 JIT编译器被允许在同步块方面享有一些自由,以降低其性能成本。 在某些情况下,可以完全删除同步,并且可以合并在同一监视器上同步的相邻同步块。 如果我们要衡量同步的成本,那么这些优化确实会损害我们的利益,因为我们不知道有多少同步(在这种情况下可能全部同步)都已被优化。 更糟糕的是,JIT优化SyncTest.increment()中的SyncTest.increment()代码的SyncTest.increment()可能与优化实际程序的方式大不相同。

但情况变得更糟。 这个微基准的表面目的是测试速度更快,同步还是ReentrantLock 。 由于同步是该语言的内置功能,因此ReentrantLock是普通的Java类,因此编译器将优化无条件同步与优化ReentrantLock -acquire不进行同步。 这种优化使无所事事的同步显得更快。 由于编译器将优化两种情况,彼此之间的差异和实际情况下的优化方式都不相同,因此该程序的结果很少告诉我们它们在实际情况下的相对性能之间的差异。

消除死代码

12月的文章中 ,我讨论了基准测试中消除死代码的问题-因为基准测试通常没有任何价值,编译器通常可以消除基准测试的整个块,从而扭曲了时序测量。 该基准通过多种方式解决了该问题。 编译器可以消除死代码的事实并不一定致命,但这是因为它能够对两个代码路径执行不同程度的优化,从而系统地扭曲了我们的测量。

这两个Incrementer类旨在执行不做任何事情(增加变量)。 但是,智能JVM会观察到永远不会访问计数器变量,因此可以消除与递增计数器变量相关的代码。 这是我们面临的一个严重问题-现在SyncIncrementer.increment()方法中的synchronized块为空,并且编译器可以完全消除它,而LockIncrementer.increment()仍然包含锁定代码,编译器可能或可能无法完全消除。 您可能会认为此代码是支持同步的要点-编译器可以更轻松地消除它-但是在无所事事的基准测试中,发生这种事情的可能性要比在现实世界中编写良好的代码要高得多。

正是这个问题-编译器可以更好地优化一个替代方案,但是这种区别仅在不执行基准测试中才变得明显-这使得这种比较同步和ReentrantLock性能的方法变得如此困难。

循环展开并锁定合并

即使编译器没有消除计数器管理,它仍可能决定以不同的方式优化这两个increment()方法。 一个标准的优化是循环展开。 编译器将展开循环以减少分支数量。 展开多少次迭代取决于循环体内有多少代码,并且LockIncrementer.increment()的循环体内比SyncIncrementer.increment() “更多”代码。 此外,当SyncIncrementer.increment()展开并且内联方法调用被内SyncIncrementer.increment()时,展开的循环将是一系列锁增量-解锁组。 因为所有这些都锁定同一监视器,所以编译器可以执行锁定合并(也称为锁定粗化)以合并相邻的synchronized块,这意味着SyncIncrementer执行的同步将少于预期的同步。 (而且情况变得更糟;在合并了锁之后,同步的主体将仅包含一系列的增量,可以将其强度降低为单个加法。而且,如果重复应用此过程,则整个循环可以折叠为一个具有单个“ counter = 10000000”操作的单个同步块。是的,实际的JVM可以执行这些优化。)

再次,问题不仅仅在于优化器正在优化我们的基准测试,还在于它能够对一个替代方案应用与另一种替代方案不同的优化程度,并且可以应用于每个替代方案的优化类型不会可能适用于实际代码。

缺陷计分卡

该列表并不详尽,但是以下是该基准测试未按照其创建者的预期进行工作的一些原因:

  • 没有执行预热,也没有考虑到JIT执行所花费的时间。
  • 该测试易受单态调用转换和后续取消优化引起的错误的影响。
  • 由同步块或ReentrantLock保护的代码实际上已失效,这扭曲了JIT优化代码的方式。 它可能能够消除整个同步测试。
  • 该测试程序希望测量锁定原语的性能,但是它没有包含竞争的影响,并且仅在单处理器系统上运行。
  • 测试程序未在足够多的平台上运行。
  • ReentrantLock测试相比,编译器将能够在同步测试上执行更多优化,但不能以某种方式帮助使用同步的实际程序。

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

关于微基准测试的可怕之处在于,它们总是产生一个数字,即使该数字毫无意义。 他们测量的东西,我们只是不确定。 很多时候,它们仅衡量特定微基准的性能,仅此而已。 但是,很容易说服自己基准测试可以衡量特定结构的性能,并错误地得出有关该结构的性能的结论。

即使编写了出色的基准测试,您的结果也可能仅在运行它的系统上有效。 如果您在具有少量内存的单处理器笔记本电脑系统上运行测试,则可能无法得出有关服务器系统性能的任何结论。 比较和交换之类的低级硬件并发原语的性能在一个硬件体系结构与另一个硬件体系结构之间有很大的不同。

现实情况是,试图用一个数字来衡量“同步性能”之类的东西是不可能的。 同步性能随JVM,处理器,工作负载,JIT活动,处理器数量以及使用同步执行的代码的数量和特征而异。 最好的办法是在一系列不同的平台上运行一系列基准测试,并寻找结果的相似性。 只有这样,您才能开始总结有关同步性能的知识。

在作为JSR 166( java.util.concurrent )测试过程的一部分运行的基准测试中,平台之间的性能曲线形状存在很大差异。 诸如CAS之类的硬件构造的成本因平台而异,并随处理器数量的不同而变化(例如,单处理器系统绝不会发生CAS故障)。 具有超线程功能的单个Intel P4(一个裸片上有两个处理器内核)的内存屏障性能比具有两个P4的内存屏障性能要快,并且两者的性能特征均与Sparc不同。 因此,您能做的最好的事情就是尝试构建“典型”示例,并在“典型”硬件上运行它们,并希望它能对真实程序在真实硬件上的性能产生一些见解。 什么构成“典型”示例? 它的计算,IO,同步和争用以及内存的局部性,分配行为,上下文切换,系统调用和线程间通信的组合近似于实际应用程序。 就是说,一个现实的基准看起来很像真实程序。

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

那么,您如何编写完美的微基准测试? 首先,编写一个良好的优化JIT。 与其他编写过最佳JIT的人会面(他们很容易找到,因为没有太多优秀的JIT!)。 让他们共进晚餐,并交流有关如何尽可能快地运行Java字节码的性能技巧的故事。 阅读有关优化Java代码执行的数百篇论文,并撰写几篇。 然后,您将具备编写良好的微基准测试所需的技能,以应对诸如同步,对象池或虚拟方法调用等成本。

你在开玩笑吗?

您可能会认为上述编写好的微基准的方法过于保守,但是编写好的微基准确实需要大量有关动态编译,优化和JVM实现技术的知识。 要编写一个能够真正测试您认为的功能的测试程序,您必须了解编译器将对其执行的操作,动态编译的代码的性能特征以及所生成的代码与典型的,真实的代码有何不同。 -world代码使用相同的构造。 没有这种程度的了解,您将无法判断程序是否可以测量所需的内容。

所以你会怎么做?

如果您真的想知道同步是否比备用锁定机制快(或回答任何类似的微性能问题),您该怎么办? 一种选择(不适合大多数开发人员使用)是“信任专家”。 在ReentrantLock类的开发中,JSR 166 EG成员在许多不同的平台上进行了数百(即使不是数千)小时的性能测试,检查了JIT生成的机器代码,并仔细研究了结果。 然后他们调整了代码,然后又重新做了一次。 对这些类的开发和分析涉及了大量的专业知识和对JIT和微处理器行为的详细了解,但不幸的是,我们无法像期望的那样在一个基准测试程序的结果中总结这些知识。 另一个选择是将注意力集中在“宏观”基准上-编写一些实际程序,以两种方式对其进行编码,制定切合实际的负载生成策略,并在实际负载条件下使用这两种方法来评估应用程序的性能和实际的部署配置。 这是很多工作,但是它将使您更接近所需的答案。


翻译自: https://www.ibm.com/developerworks/java/library/j-jtp02225/index.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值