JVM_deoptimization

Posted by MegaBillow on Thursday, November 25, 2021

Trino 官方 blog 的一篇 comcast user case 中讲到了在大查询情况下,任务长时间无法完成的 case,通过 explian 看到的估计执行时间是以天计。

通过分析跟踪,发现 worker 之间的 cpu 使用时间不均衡,有较大差异,从分钟级到数小时约有30倍的差距。Starburst 的工程师给出的诊断是与 JVM 的 code deoptimization 有关。翻看之前 FFcompute 公众号在文章中提到的 cpu 使用时间不均的问题,也是与此相关。

在此对 JVM optimization/deoptimization 进行一些梳理。

JVM optimization/deoptimization 的目的和发生时机

open jdk wiki 中的定义

Deoptimization is the process of changing an optimized stack frame to an unoptimized one. 

在针对一个 method 执行 re-compiling 一定次数之后,JVM 会拒绝再进行 compiling,而是转为 interpreted mode,所以执行明显变慢。

JVM HotSpot 的多级执行模式,需要在 JIT compiler 和 Java 应用之间进行资源使用的折中。

JIT 编译

JIT 同 GCC 等 offline 编译器的一大差异就是在应用的进程中运行,所以需要占用应用的资源,与应用发生资源竞争,需要考虑诸多平衡因素。

Java JIT 以 .class 文件作为输入,将其转换为 CPU 执行的机器指令。通过加入 -XX:+PrintCompilation 可以得到 JIT 的编译输出。有些方法不会被 JIT 编译,这些方法由 HotSpot 内置的解释器解释执行,这是最为通用的方式。所有的方法在开始都是解释执行的。在执行中,一些方法转为 compiled 代码加快执行,这就是 optimization 的作用所在。理想情况是将全部代码都转为 compiled 后在执行,但是这样会导致应用的执行产生较大的延迟。

HotSpot JVM 执行模式基于以下四种观察:

  1. 大部分 code 很少被执行到,所以对其执行 compile 相当于浪费资源。
  2. 只有少量方法会频繁执行。
  3. 解释器可以保证所有代码的正确执行。
  4. 编译后的 code 速度更快,但是需要大量资源且耗时较长。

由此导致的执行模式为:

  1. 代码立即以解释的模式开始执行。
  2. 多次被执行的方法通过 JIT 编译。
  3. 一旦 compiled 代码生成,切换到 compiled 模式执行。

解释器会增强代码,记录每个 method 的执行次数,也包括循环执行的次数。次数超过阈值,就会将 method 放入 compilation 队列。一个与应用并行的 compiler 线程专门处理这些 compilation 请求。在 compile 过程中,依然通过解释器来执行,一旦 compile 完成,就切换到 compiled 代码来执行。

HotSpot 多级执行

为了更好的在解释器执行和 compiled 执行之间进行平衡,HotSpot 设计了一个 3 级系统,包括:解释器、quick compiler、optimizing compiler。每一级都是在执行延迟与执行速度之间的权衡。在 HotSpot 中,quick compiler 被称为 C1 或 client compiler,optimizing compiler 称为 C2 或 server compiler。

在样例程序中的输出中,第三列的数字表示代码编译的层级,1-3表示由 quick compiler 中编译,4 表示由 optimizing compiler 编译。

deoptimization 的介绍

需要注意的是,在实际场景中不仅有从 解释执行 -> C1 编译 -> C2 编译 的执行迁移,也可能发生反向的执行迁移,即:从 C2、C1 编译到解释执行的情况,也就是本文标题中的 deoptimization。发生反向迁移的原因主要有:

  1. 为了避免使得 compiler 过于复杂,一些 compiler 不支持的不常用的边界情况出现时,会发生 deoptimization。
  2. 主要的原因是允许 JIT compiler 进行 speculation(预测或投机)。而当假设生成的代码无效时,就会发生 optimization。(如何的判断假设错误或者无效?)

在 openjdk 的 wiki 中有更加详细的列举。

deoptimization 事件分为两类:

  1. 同步事件:由执行 compiled code 的线程发出,这类事件在 HotSpot 中被称为 uncommon traps。
  2. 异步事件:由其他线程发出。例如,class hierarchy analysis 的问题。这篇文章 中有具体的示例。

deoptimization 只能在 safepoint 发生。 deoptimization 会 reconstruct 执行代码使得解释器可以恢复被中断的执行 compiled 代码的线程。在 safepoint 中,维护了解释器状态和 compiled code 的对应。

为 deoptimization safepoint 生成的符号信息是 debugInfo,指出如果要把当前栈帧从 compiled frame 转换为 interpreted frame 的话,要从哪里把相应的局部变量、临时变量、锁等信息找出来。

deoptimization 的同步和异步执行,对于 safepoint 的处理是不同的,在这篇文章中有较为详细的介绍。

deoptimizing zombie code

deoptimization 并非全是负面影响。当 deoptimization 发生时,之前生成的 compiled code 被标记为 not entrant 存在于 code cache 中,随后的 GC 会回收这些对象,compiler 会将这些方法标记为 zombie code,并从 code cache 中清除,从而为其他 compiled code 腾出更多的空间。

获得更多信息

-XX:+UnlockDiagnosticVMOptions
-XX:+TraceClassLoading
-XX:+LogCompilation 

这三个 jvm 配置会提供更多的诊断信息,会产生名为 hotspot_pid<SOME_PID>.log 的文件。通过工具 JITWatch 可以读取该文件的内容,进一步分析 JIT 相关代码的情况。

如何发现 deoptimization 的发生

  • 在 Presto web UI 中看到不同 worker 之间的 CPU 用时之间的差异较大。其他应用场景可以通过类似的方式进行观察。
  • 通过 pstack 查看线程执行栈,看到大量 CPU 耗时集中在对 Deoptimization::uncommon_trap 的调用

解决 deoptimization 带来的性能影响

  • JDK 版本低于 8u60, 11.0.5 的情况需要设置 jvm 参数 以增加停止 recompilation 的次数阈值

     -XX:PerMethodRecompilationCutoff=10000
     -XX:PerBytecodeRecompilationCutoff=10000
    
  • JDK 11.0.5, 8u60 中被修复

参考文档

  1. https://developers.redhat.com/articles/2021/06/23/how-jit-compiler-boosts-java-performance-openjdk#deoptimization_and_speculation
  2. https://shipilev.net/jvm/anatomy-quarks/29-uncommon-traps/
  3. https://www.h2o.ai/blog/java-hotloops/ HotSpot C2 设计者 Cliff 关于 optimization 的介绍。
  4. https://mp.weixin.qq.com/s/OK39zkUj-kJfY6HYixj_Lw
  5. https://www.cs.princeton.edu/picasso/mats/HotspotOverview.pdf