`

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

阅读更多
众所周知,软件工程师常常受到性能问题的困扰,有时候甚至很过分。虽然有时候性能在一个软件项目中是最重要的需求,例如在为高速交换机开发协议路由软件时 便是如此,但在大多数情况下,需要在性能需求与其他需求之间进行平衡,例如功能性、可靠性、可维护性、可扩展性、投入市场的时间以及其他业务和工程上的考 虑。在本月的 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理论与实践: 一个有缺陷的微基准的剖析

    本文通过分析一个名为 SyncLockTest 的微基准例子,揭示了评估 Java 中 `synchronized` 关键字与 `ReentrantLock` 类性能时可能遇到的问题。 首先,`SyncLockTest` 通过执行百万次的 `increment()` 方法来测量时间...

    基准数据库的建立

    特别是在软件工程领域,通过对历史项目的深入分析,可以提炼出有价值的基准数据,进而指导未来的项目管理和决策制定。基准数据库作为软件量化管理的核心组成部分,对于提升企业的研发效率和产品质量具有重要作用。 ...

    基准及分类PPT课件.pptx

    一般情况下,粗基准只在第一道工序中使用一次,避免因多次使用而产生误差积累。 5. 便于装夹原则。选择粗基准时,应确保表面平整、无缺陷,方便工件的稳定装夹,提高加工效率。 通过《基准及分类》课件中提出的...

    电子政务-双基准电容外观图像检测方法.zip

    另一个是已知存在某种特定缺陷的电容图像,作为缺陷基准。这两个基准有助于建立一个全面的评估框架。 4. 特征提取:通过边缘检测、形状描述符(如HOG、SIFT等)等方法,提取电容图像的关键特征,包括形状、颜色、...

    用于自动程序修复研究的可扩展Java错误基准_Python_下载.zip

    "用于自动程序修复研究的可扩展Java错误基准_Python_下载.zip"是一个专门针对这一领域的资源包,其中包含了Bears(Bug-Enhanced Repository for Automatic Repair Evaluation and Study)基准,以及Python相关的工具...

    2019年中国软件行业基准数据解读-王海青.pdf

    综上所述,《2019年中国软件行业基准数据解读》不仅是对2019年行业数据的全面梳理和分析,更是一份为软件行业提供决策支持的宝贵资料。通过对基准数据的细致解读,行业内的企业和专业人士能够更加清晰地认识到自身在...

    基于处理器硅前性能验证平台的基准程序库设计方法.pdf

    为了解决这个问题,研究人员提出了一个创新的基准程序库设计方法,以应对硅前验证阶段的新挑战。 传统的基准测试程序集如SPECP2000和SPECCPU2006,在处理器性能评估方面有着广泛的应用。它们通过模拟实际应用程序的...

    2023 年 中国软件行业基准数据(CSBMK-202310)

    - 涵盖了软件开发周期中的多个阶段,包括需求分析、设计、编码、测试等。 - 数据按行业、软件类型、项目规模等维度进行分类,便于针对性地分析和应用。 ##### 3.3 数据处理流程 - 数据清洗:去除无效或异常数据,...

    CK3-CSBMK-2022年中国软件行业基准数据.pdf

    CSBMK-202210报告所提供的行业基准数据,正是基于大量实际项目的收集和分析得出的,这些数据包括软件开发生产率、应用软件运维生产率和软件质量等关键方面。 在数据的采集和处理过程中,报告采取了多种质量保证措施...

    基于超声导波SC-DTW的金属板微损检测方法.pdf

    该方法可以检测到金属板材中的微小裂纹、缺陷等微损伤,从而评估金属板材的质量和服役性能。 知识点2:形状上下文(Shape Context) 形状上下文(Shape Context,SC)是一种基于形状特征的匹配算法,用于对Lamb波...

    土木工程专业常见工程事故案例分析

    这一主题涵盖了桥梁、道路、建筑结构等多个方面的工程事故,旨在提高工程师们的安全意识和问题解决能力。 1. **桥梁工程事故**:桥梁建设过程中可能遇到的问题包括结构坍塌、裂缝、不均匀沉降等。这些事故通常由...

    HiP-WTJE-ED-004实装焊接判断基准--最终版.pdf

    8. **处理规则**:明确了同一组件的多个不良被视为一个不良的规则,以及以基板或组件为单位的计数方法。 9. **变更记录**:文档中包含了历次修订的日期和内容,如漏铜点检标准、Key板金焊盘异物附着基准的变更和CN...

    基于条件概率模型的缺陷定位方法.pdf

    《基于条件概率模型的缺陷定位方法》这篇论文探讨了软件调试中的一个重要环节——缺陷定位,尤其是在程序频谱信息基础上的定位方法。当前,基于频谱的缺陷定位(SFL)被认为是一种较为有效的策略,它假设程序频谱与...

    Linux文件系统健状性测试与分析.pdf

    健壮性测试是软件测试的一个重要组成部分,它可以帮助开发者更好地了解软件系统的性能和可靠性,从而提高软件系统的健壮性。常见的健壮性错误包括不合法的输入、数字溢出、后台用户超支等。 本文对Linux操作系统的...

    基于机器视觉的PCB裸板缺陷检测方法研究.pdf

    该论文通过测试100幅合格PCB图和100幅有缺陷的PCB图,对所提出的检测方法进行了评估。这些测试数据有助于分析和改进算法,以提升系统的检测准确率和检测效率。 在硬件设备的选择方面,文章指出了选择合适的摄像机和...

    硬盘测试工具 Parkdale 3.03.zip

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

    轨道表面缺陷数据集,可以做测试

    4. 缺陷标签:每个图像或数据点都会附带一个或多个标签,表明了缺陷的类别,如“裂纹”、“磨损”等,这有助于训练和评估算法的分类性能。 利用这个数据集,研究人员和工程师可以进行以下工作: 1. 计算机视觉算法...

    功能梯度夹芯夹层板的轴对称弹性分析

    通过综合这些知识点,可以看出功能梯度夹芯夹层板的轴对称弹性分析是一个多学科交叉的研究领域,不仅涉及到复合材料的基本理论,还包括了数学建模、数值分析以及材料科学的应用。此类研究对于理解夹芯板结构的力学...

Global site tag (gtag.js) - Google Analytics