`

一个有缺陷的微基准的剖析

阅读更多
众所周知,软件工程师常常受到性能问题的困扰,有时候甚至很过分。虽然有时候性能在一个软件项目中是最重要的需求,例如在为高速交换机开发协议路由软件时 便是如此,但在大多数情况下,需要在性能需求与其他需求之间进行平衡,例如功能性、可靠性、可维护性、可扩展性、投入市场的时间以及其他业务和工程上的考 虑。在本月的 Java 理论和实践 中,专栏作家 Brian Goetz 将探讨为什么度量 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 和微处理器行为的深度理解,不幸的是,凭一个基准程序的结果就下结论仍然过早,虽然我们也想这样。另一种选择是,将注意力放在“微”基准上 —— 编写一些实际的程序,用两种方法编写代码,开发一种逼真的负载生成策略,并在逼真的负载条件下和逼真的部署配置中使用这两种方法来度量应用程序的性能。这 样做工作量会很大,但惟有如此才能更接近您想要的答案。

 

 

 

 

 

 

转自http://www.ibm.com/developerworks/cn/java/j-jtp02225.html

 

 

分享到:
评论

相关推荐

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

    并指出,应把注意力放在“微”基准上--编写一些实际的程序,用两种方法编写代码,开发一种逼真的负载生成策略,并在逼真的负载条件下和逼真的部署配置中使用这两种方法来度量应用程序的性能。这样做工作量会很大,但...

    基于Github的Java程序缺陷自动修复

    内容概要:通过带着读者设计并实现了一个基于 Github 的 Java 程序缺陷修复原型系统 VulRepair。并且为了验证 VulRepair 的缺陷修复效果,还设计并实现了一组评估对比实验,从多个角度评估验证本文提出的缺陷修复...

    硬盘测试工具 Parkdale 3.03.zip

    Parkdale 中文版为您提供了一个坚固而整洁的工具,主要用于测试硬盘驱动器的性能,同时还可以帮助您对光驱和网络连接进行基准测试。此外,它可与USB闪存驱动器一起使用,并通知您捕获的速度是低速还是最佳速度。 ...

    GPT-4的缺陷和风险

    例如国外一位网友试图让GPT-4总结一个视频,GPT-4给出的答案却是一套关于”深空“的理论。 此外,GPT-4主打的多模态生成模式是否也会进一步带来生成具有政治导向、错误价值观、暴力倾向等内容的风险呢,那么如何灵活...

    决策信息系统(有运畴学、工程经济学等内容) VB+SQL

    本模块主要用于生成备选方案,在对市场分析预测之后,投资者可能会有一个或多个投资项目,而每个投资项目中可能会有一个或多个备选方案;用户可在本模块中将备选方案的基本信息存于数据库中; 本模块操作说明:①...

    论文研究 - 2017年达喀尔·纳比尔·乔凯尔健康中心(塞内加尔)产科的胎儿工具提取:流行病学-临床方面,适应症和预后

    这项工作的目的是描述在达喀尔的一个基准产妇科中,通过工具提取辅助分娩的流行病学-临床和预后方面。 材料和方法:这是一项回顾性,描述性和分析性研究,于2017年1月1日至2017年12月31日进行,为期12个月,在Nabil...

    文档智能:数字化转型的技术基石.docx

    随着深度学习预训练技术的发展,以及大量无标注电子文档的积累,文档分析与识别技术进入了一个全新的时代。 微软亚洲研究院提出的 LayoutLM 便是一个全新的文档理解模型,通过引入预训练技术,同时利用文本布局的...

    css-in-js:全面分析所有当前CSS-in-JS解决方案,并具有对Next.js的SSR和TypeScript支持

    对CSS-in-TS的全面分析本文包含对所有当前支持服务器端... 但是,在component.js文件和component.css文件之间来回移动,搜索给定的类名并且无法轻松地“转到样式定义”的经验是一个重要的生产力缺陷。 设置伪内容和媒

    论文研究-基于Copula-CVaR-EVT方法的供应链金融质物组合优化.pdf

    异于股票、债券等金融资产组合基于短期风险预测优化框架,提出一类更具普适性的基于蒙特卡罗模拟法的质物组合长期风险预测方法,克服现有长期风险预测中视为基准的时间平方根法则缺陷;比对银行采取积极和保守投资策略,...

    啤酒瓶检测中多分类支持向量机算法的选择* (2009年)

    本文通过一对一、一对多、决策有向无环图、二叉树、误差纠错码、一次性求解等多分类支持向量机算法在核函数为线性、多项式、径向基,神经网络的情况下,对多个基准样本进行了分类性能、分类速度、分类准确性的详细...

    APQP程序文件(1).pdf

    本程序确定和制定确保产品使顾客满意所需的新产品开发的步骤,促进与所涉及的每一个项 目开发小组成员的联系,以确保所要求的步骤能按时完成,并引导资源,预防缺陷,降低成 本,持续不断地改进,以最低的成本及时...

    软件项目中如何开展有效的需求评审

    在实际的项目过程中,需求阶段往往是由一两位需求分析人员与用户沟通用户需求,然后根据自己的理解输出软件需求说明书及软件原型接下来的项目计划、软件设计、编码、测试等各个环节都以此为基准。俗话说,当局者迷,...

    基于TMS320F2812数字控制的三相逆变电源设计论文WORD文档+ALTIU设计硬件原理图PCB文件.zip

    摘要:随着社会的需求越来越高,传统的模拟电源的诸多缺陷越来越凸显, 本文在借鉴国内外相关研究的基础上,通过对空间矢量脉宽调制算法的分析,研究了数字信号处理器生成SVPWM 波形的实现方法及软件算法。...

    带有抑制竞争激活机制的改进竞争Hopfield网络,用于解决最大集团问题

    在本文中,我们分析了离散竞争Hopfield网络(DCHOM)中的权重定义公式,并指出了它在解决某些最大集团问题(MCP)的特殊情况时的缺陷。 在分析的基础上,我们提出了一种改进的竞争Hopfield网络算法(ICHN)。 在ICHN...

    浅析计算机软件可靠性测试与设计.doc

    4.3 软件编码 软件错误的一个很重要的来源就是编码产生的不足,而编码是在软件设计的基础上完成 的。设计与生产分离是有效降低编码错误的方法。 5 小结 综上所述,软件可靠性设计工程已经得到大家的认可,但在其在...

    套管成孔灌注桩施工.doc

    有套管护壁,可避免坍孔、瓶颈、断拉、移位、脱空等缺陷,质量可靠;能沉能拔,施工速度快,效率高,操作简便安全。本工艺标准适用于工业与民用建筑基础土质为一般粘性土、淤泥、淤泥质土。稍密的砂土及杂填土土层...

    MAPGIS地质制图工具

    6、点存储,就会存储这个钻孔的数据,如果你还有钻孔,就继续在另外一个钻孔位置点击一下,输入数据,点存储,如果没有了,就点退出。 7、输入探槽数据。选择读取探槽数据,在探槽起点的地方点击一下,会出现探槽...

    软件资料文档标准规格

     ◇ 开发进度月报:该月报系软件人员按月向管理部门提交的项目进展情况报告,报告应包括进度计划与实际执行情况的比较、阶段成果、遇到的问题和解决的办法以及下个月的打算等。  ◇ 项目开发总结报告:软件项目...

    MySQL 5.1中文手冊

    在同一个数据库中创建多个表的缺陷 7.5. 优化MySQL服务器 7.5.1. 系统因素和启动参数的调节 7.5.2. 调节服务器参数 7.5.3. 控制查询优化器的性能 7.5.4. 编译和链接怎样影响MySQL的速度 7.5.5. MySQL如何使用内存 ...

Global site tag (gtag.js) - Google Analytics