我们如何击败HotSpot性能(通过合理的手段)
[LOADING...]
不,我们没有在基准测试中作弊。至少我希望没有。这个优化故事中的每一步都通过比特相同的输出校验和与 HotSpot 对比进行验证,并且当校验和不同时,测试框架会拒绝输出比率。如果说有什么的话,这篇文章更想说明 HotSpot 有多优秀。我们用尽一切可能倾斜天平,我们手工调优了 C 代码,但最终也只在部分基准测试中击败了它。达到这个目标确实是一场艰苦的战斗。如果你想了解 Java 代码的实际开销,以及每个运行时所做的权衡,希望这篇文章值得一读。
简而言之:PR #5327 将 ParparVM(用于 iOS 和其他平台的 AOT 虚拟机,负责将 Java 字节码编译为 C)的性能,从比热机 Java 25 慢 4.21 倍,提升到十项基准测试集合的几何平均 1.00x 持平,其中有六项达到或低于 HotSpot。在此过程中,你将了解一个新的基于页面的堆、一个穷人版 Project Valhalla、让 clang 终于能发挥作用的代码生成,以及两处即使我们手工编写 C 代码仍然被 HotSpot 击败的地方。而内存方面的表现甚至比速度更好。
不过在此之前,先做几项声明。
更新前请知悉
以下优化在本周落地。每次提交的性能都通过比特相同输出与 HotSpot 对比验证。正确性方面更进一步:在多个端口上运行了 63 个测试流水线,包括针对映射、字符串构建器、线程和 GC 压力的压力测试套件,涵盖协作式和强制信号停止模式,我们在此过程中修复了发现的每一个错误,其中几个错误出现在远离 VM 的端口中。但这是一次对代码生成、分配和回收的深度变更。任何此类规模的变更都存在风险。
如果构建出现异常,请使用版本化构建锁定到上一个版本,并通过常规渠道告知我们。这正是版本化构建存在的意义。
本周除了 VM 优化外还有更多更新;本文底部链接了相关的每日报道。
起点
客户端虚拟机是另一种存在。这里的玩笑是,我花了两周时间构建了 ParparVM,而 Steve 花了接下来的三年时间修复 bug。当初构建它时,吞吐量根本不是优先考虑的事。我的目标是简单、可靠和一致性。对客户端性能真正重要的是启动时间、内存占用、低延迟和快速原生访问。90% 的客户端代码时间应该花在渲染和 IO 上,而在 Codename One 中,这些路径是每个平台上手工定制的原生代码,已经精细调优。
偶尔会有客户抱怨性能,我们会针对特定情况添加优化,然后继续前进。我们对常见用例进行了剖析,发现它们没有问题,因此从未将 VM 吞吐量视为问题。与 HotSpot 比较甚至不在考虑范围内。你无法超越 JIT 的优化能力,而且我们本来也不在相同的平台上运行。现在我们有了桌面端口,人们开始移植更重量级的工作负载,并在相同硬件上直接进行比较。我本以为我们的速度是热机 HotSpot 的 2 到 3 倍,还不考虑启动时间。
但我错得非常离谱。测量这个需要一些改造:ParparVM 通常只用于运行 Codename One,所以我们将其修改为独立运行,不依赖框架,并且测试套件只测量 VM 本身,而不是其上的框架。参考基准是经过完全热机的 OpenJDK 25,而热机是公平的衡量方式:Java 25 携带预加载的类和方法分析文件,因此部署的应用可以快速达到该热机状态,而不是通过数千次迭代来累积。以下是 Apple M2 上的起点,两个运行时的校验和完全相同:
几何平均:慢 4.21 倍。这真的很糟糕。
剧透:经过以下所有优化后,同一套测试的结果如下。
几何平均 1.00x。低于 1.0 表示我们击败了热机 HotSpot C2。相同的 Java 源代码,相同的校验和,取 5 次交错运行的最佳值。交错运行并非装饰:早期在 M2 上顺序执行 A 然后 B 的计时会带来 10% 到 15% 的热偏差,使一个只有 3% 提升的改动显得像 1.5 倍提升,因此测试框架在一个运行中交替执行两个 VM。
在您提问之前,有一个方法论说明:比较是在 macOS 上进行的,因为 HotSpot 不能在 iOS 上运行。相同的生成的 C 代码会交付到所有 Apple 目标,因此代码生成的优化优势也会传递到 JIT 无法跟进的设备上。
[LOADING...]
越低越好,1.0 是 HotSpot;intArithmetic 左侧的所有项都领先于 HotSpot。起始比例甚至无法放在这张图上——hashMapChurn 开始时是 36 倍。
内存方面的表现
速度只是报告的一半,对于客户端应用来说,它甚至是不太重要的一半。RAM 才是这个版本改进最大的方面:
[LOADING...]
master 分支的 GB 级峰值暴露了一个真正的 GC bug:自上次回收以来分配量的计数器是一个 32 位整数,按字节计数。每个周期分配数 GB 的工作负载会使其溢出为负数,"我是否在快速分配?" 的检查回答为否,于是收集器在风暴中继续休眠,而死页则不断堆积。修复该触发条件后,重负载下的峰值甚至低于 JVM,而空闲基线仍保持在手机所需的水平:2.4 MB。请细细品味这一对比,因为这正是这个 VM 的核心理念:在速度上与热机 HotSpot 一较高下,同时保持 2.4 MB 的空闲基线,而 HotSpot 则是约 40 MB。
我们是怎样“作弊”的?
我们相对于 HotSpot 的一个优势是:我们并非真正的 Java。至少并非完全如此。我们自称是一个让 Java 开发者能够将 Java 应用部署到移动设备的工具。明白了吗?你编写的是 Java 代码,所以它是个 Java 应用。但在一些重要方面,我们并非真正的 Java,这给了我们 HotSpot 所没有的自由度。
我们编译的是封闭世界。不存在动态类加载,因此我们知道所有可能存在的类。不存在反射(Class.forName() 以有限形式存在,大致如此),因此没有任何方法调用的方法可以被删除,只有一个可达实现的虚调用可以被优化为直接调用。API 更小,因此需要保留的遗留行为更少。我们还可以在一些微妙的 VM 行为上灵活处理,而 HotSpot 必须精确保持(更多关于下面的 Integer 同一性)。
如果没有这些作弊手段,我们会在该组所有基准测试中落败。HotSpot 承载了四分之一世纪的工程积累,我们不可能在“公平”条件下接近它。即使拥有这些结构优势,我们仍拼尽全力才达到有竞争力的水平。
C 编译器实际看到的是什么
ParparVM 将字节码转换为 C,然后让 clang 优化。所以整个游戏就是:生成的 C 代码在多大程度上类似于人类手写的 C 代码?一开始,答案是“完全不像”。每个 Java 方法都会推入一个 GC 可见的帧,包含类型标记的槽,并将每个中间值都通过该帧传递:
该帧是 GC 找到活动对象的方式。这也是 clang 无法将任何内容保留在寄存器中的原因。现在,翻译器可以证明某些方法安全(无 try/catch、无同步、对象根由原生栈扫描覆盖),它们会被编译成你手写的 C:
通过这个及其后续优化,递归从 7.6 倍降至 1.6 倍。更重要的是,无帧代码生成推动了所有其他优化,因为一旦值存在于寄存器中,clang 的其余优化器就会被唤醒。
为什么我们仍然在递归上失利
递归上剩余的 1.6 倍来自 HotSpot 的推测性内联,我们接受了这一点。JIT 观察程序运行,注意到 fib 调用 fib,就将其内联到自身若干层深,并保留一个去优化出口,以防其推测错误。而提前编译器无法赌博。它必须发出对每种可能执行都正确的代码,因此真实的调用仍然是真实调用。这是 JIT 的结构性优势,再多的手工调优也无法弥补。如果你的工作负载是深递归调用链,那么 HotSpot 无疑是更适合的机器。
毒害每个循环的边界检查
Java 要求每次数组访问都进行索引检查。检查本身成本很低。但破坏性能的是失败路径的形状。我们的边界检查辅助函数抛出异常后返回一个哑值,因此“失败”路径会重新加入循环。这意味着每个循环迭代都包含一个可到达的函数调用,而 clang 必须假设调用可以修改任何内存。因此,它在每次迭代中都从内存重新加载数组指针和数组长度:
修复方法是让失败路径分离。抛出异常并从方法返回,就像栈溢出保护那样。现在循环的任何路径都不包含调用,clang 可以将加载提升到循环外:
我们将该检查与纯 C 控制组在相同标志下进行了测量:原始 C 在 91ms 内完成排序,带有分离式边界检查的 C 在 98ms 内完成。因此,当周围代码正确时,Java 强制要求的安全措施大约消耗 8%。修复后,基准排序从 216ms 降至 164ms,而 HotSpot 为 197ms。快速排序最终以 0.92x 低于 HotSpot,同时保留了所有检查。
HotSpot 击败我们手工调优 C 的地方
在 intArithmetic 和 longArithmetic 上,我们编写了纯 C 控制组,没有 VM,没有 GC,只有循环,使用相同的 clang 标志编译。生成的 ParparVM 代码与手写 C 完全持平:94.0ms 对 93.7ms,以及 59.7ms 对 59.3ms。VM 开销为零。
但 HotSpot 依然获胜,分别为 1.07x 和 1.12x。剩余的差距在于 C2 重新关联了循环携带的依赖链,调度得比 clang 更好。在紧致的算术循环中,对于相同的语义,HotSpot C2 生成的机器码优于 clang -O3。JIT 看到了实际的热循环及其实际依赖图,并精确地针对它们进行优化。我们宣布这些基准测试到此为止,因为当你的差距与 clang 和 HotSpot 之间的差距相同时,VM 就不再是问题了。
有一个值得任何生成 C 的项目注意的脚注:Java 整数语义要求 -fwrapv -fno-strict-aliasing。没有 -fwrapv,clang -O3 会确定性地错误编译溢出的累加循环。我们的校验和门捕捉到了这一点,每个溢出正好相差 2^32。## 内存:两种哲学
最糟糕的初始数据(20倍到36倍差距)与代码生成完全无关。这些差距来自内存管理,要理解它们,你需要了解两个虚拟机对内存截然不同的思考方式。
HotSpot 一次性向操作系统申请一大块内存,在内部管理一切。这换来了极高的速度。分配就是在线程本地缓存区中移动指针(pointer bump),而由于 HotSpot 拥有整个地址范围,一个简单的地址比较就能判断对象属于哪个区域或代。拥有地址所有权简化了整个虚拟机的推理:屏障、卡片表、代际检查等,当“这个对象在哪里?”只是一个算术问题时,所有这些都变得更廉价。
ParparVM 走了另一条路:我们通过操作系统的 malloc、calloc 和 free 进行分配。多年来,这看起来像是一个缓慢且天真的选择,部分实现确实很慢。但我们仍然坚持这样做,因为它带来了客户端虚拟机比基准测试分数更看重的三样东西:
- 直接原生访问。原生代码和 GPU 将我们的对象视为普通内存,无需钉住(pinning)或在堆边界复制。这使得快速 SIMD 和渲染路径变得实用。
- 真正的工具支持。原生性能分析器和泄漏检测器(Instruments、Valgrind 等)能理解我们的内存,因为这是普通内存,而不是它们无法窥视的黑箱块。
- 真实的内存占用。进程只持有应用实际使用的内存。在资源受限的设备上,这更小,操作系统报告给你的内存就是你的应用真正消耗的成本。
一大袋页面(BiBOP)
19.6 倍的 objectAllocation 差距是将上述哲学过于字面化的代价。每一个 new 都是一次 malloc、一次完整的 memset 清零,以及在锁下进行一次 O(n) 的跟踪槽搜索。
新的分配器是一个 BiBOP 堆("big bag of pages"),这是该 PR 中最大的单一架构变更。堆被组织成页面,每个页面只存放同一大小类型的对象。对象的大小类型在编译时已知,因此 C 编译器看到的分配就是一个指针递增:
没有清零操作,因为构造函数已经写入了每个字段,并且类指针是最后存储的,所以并发收集器永远无法追踪到一个构造了一半的对象。当一个页面中的所有槽都变为垃圾时,该页面可以一步重置为从头开始递增,而无需逐个槽回收。而这些页面本身仍然来自操作系统分配器,因此上述所有优势依然成立。objectAllocation 从 19.6 倍降到了 1.19 倍。
这就是两种哲学之间的折中。我们借用了 HotSpot 设计中使分配变快的部分(递增的页面),但没有借用我们不想要的部分(一个操作系统无法窥视的巨大预保留堆)。
简单解释 GC
ParparVM 的 GC 从不停止世界(stop-the-world)。你的应用线程持续运行,而一个后台收集线程遍历对象图并标记所有可达对象,然后清扫未被标记的对象。线程之间相互协作:每个线程要么在安全点签到,要么收集器通过信号短暂中断它,捕获其寄存器和栈进行扫描,然后让它继续运行。
[LOADING...]
为什么要这样构建?因为在客户端设备上,暂停时间是唯一用户能感觉到的 GC 指标。在滚动动画期间发生 30 毫秒的收集暂停,就是两帧丢失,用户能看到。并发收集器用吞吐量换取动画永不卡顿的保证。
为什么我们不想要代际 GC
标准的服务器方案是代际收集器:在新对象分配区(nursery)分配新对象,频繁回收,将存活对象复制出去。它是一个吞吐量机器,对服务器来说是正确的选择。我们在这个分支上尝试了 nursery。objectAllocation 改善了,但 hashMapChurn 变得更糟,从 32 倍到了 43 倍。复制收集器要为存活对象付出代价,而一个持有其条目的映射会使所有对象存活。UI 工作负载经常如此:表单背后的对象图大部分存活,一帧又一帧。
代际收集器还会移动对象,这对我们有两个问题。原生代码持有指向我们堆的指针,而压缩收集器需要修复这些指针或钉住原生能看到的对象,这将放弃我们列为核心优势的直接原生访问。而且复制需要额外的空间,在收集期间大约需要两倍于存活集的大小,这正是 2.4 MB 内存占用的客户端所没有的。我们的收集器从不移动对象。BiBOP 页面回收提供了 nursery 承诺的廉价回收好处,而无需复制任何内容。
穷人的 Valhalla
hashMapChurn 基准测试耗费大量时间在自动装箱上,像 map.put(i, i * 2) 这样的代码在标准 JVM 上(除了 -128 到 127 缓存)每次调用都会分配一个 Integer。Project Valhalla 是 Java 历经十年的努力,旨在让这类值变得廉价。但我们不必等待它,因为我们控制整个栈。
在 64 位目标上,Integer.valueOf() 不再分配任何东西。它返回一个带标记的指针(tagged pointer):低位是 1,整数值存储在高位。真实对象指针是对齐的,因此它们的低位总是 0,两者永远不会冲突:
GC 完全忽略标记值,派发(dispatch)在遇到标记值时替换为 Integer 的类,拆箱就是一个移位操作。装箱变得免费:该 PR 的 A/B 测试显示,禁用标记时 hashMapChurn 为 2.8 倍,启用时为 0.97 倍。64 位目标默认启用,通过 -DCN1_DISABLE_TAGGED_INT 作为逃生舱。
这有一个语义上的代价,与 Valhalla 相同:Integer 失去了同一性(identity)。两个持有相同值的装箱对象现在实际上是相同的值,因此对装箱对象使用 == 的行为就像 JDK 的小值缓存扩展到整个范围,而 synchronized(someInteger) 变得毫无意义。JDK 本身已经弃用了包装器构造函数,并多年来警告不要对基于值的类(value-based classes)加锁。为了避免在运行时静默失败,PR #5338 在编译时拒绝在原始包装器上使用 synchronized,并提示使用专用的锁对象。如果你的代码对 Integer 加锁,它在精神上已经在现代 JDK 上损坏了。现在它在发布前就会大声失败。
不再存在的对象
另外两个变化与标记整数共享同一个主题:最快的对象就是你从未分配的对象。
融合对象。 new String(...) 曾经是两个堆对象:String 和它的 char[]。现在它们是一个块,一次分配,一个 GC 槽,两者之间没有指针跳转:
这以 @Fused 形式提交,内部应用于 String 和 StringBuilder,并且可以在你自己的包装原始缓冲区的类上使用。
逃逸分析。 javac 将 "item-" + i + "/" + n 编译成一个 StringBuilder 链。控制流遍历证明构建器从未逃逸出表达式,因此构建器及其缓冲区现在存在于 C 栈上。整个拼接过程中唯一的堆分配是最终的 String。结合其他优化,stringBuilding 达到了 0.67 倍,用时只有 HotSpot 的三分之二,而且这个基准测试首先被重建为对 HotSpot 公平(原始形态允许 HotSpot 自身的逃逸分析完全删除 String,所以我们让两个虚拟机都物化每个字符串)。
更深入
优化是一个无底洞。每修复一个问题都会暴露下一个杠杆,部分纪律是决定在哪里停止。我们暂时停在这里,因为在几何平均上达到持平意味着对于真实应用来说,VM 不再是瓶颈。下一层级的蓝图已经绘制好,如果有工作负载需要,我们会继续挖掘:
- 递归 保持在 1.6 倍,大致会停留在这里。投机内联是 JIT 的主场。
- 紧凑算术 是 1.07 倍到 1.12 倍,这是 clang vs C2 的比较,不是我们 vs HotSpot。
- 跨文件内联 是下一个大杠杆。我们故意每个类生成一个可读的 C 文件。你可以打开输出、阅读并在 Xcode 中调试,我们不愿意为了一个巨大的合并源文件而放弃这一点。代价体现在调用链密集的代码中:一个真实的地图渲染工作负载仍然落后于预热后的 HotSpot,因为有一条按字节的
readByte()链,HotSpot 可以跨类边界内联它。ThinLTO 已经收回了部分差距。剩下的部分直到有真实需求时才会处理。
这些机制的代价很小,可以精确说明:基准测试应用的二进制文件从 434 KB 增长到 451 KB。这 17 KB 换来了内联快速路径、紧凑 HashMap 和逃逸分析。
整套代码在仓库的 vm/benchmarks 目录下,包括压力测试和校验和门禁:
TL;DR
- ParparVM 从比预热后的 Java 25 慢 4.21 倍变为几何平均 1.00 倍,十个基准中有六个达到或低于 HotSpot,通过比特相同的输出校验和验证。
- 在重分配下,峰值内存低于 JVM(290 到 390 MB 对比 508 MB),基础占用为 2.4 MB,而 HotSpot 约为 40 MB。
- 背后的架构:无框架的 C 代码生成、发散边界检查、基于 OS 分配器的 BiBOP 页面堆、栈分配字符串构建,以及标记整数。
- 标记整数改变了
Integer的同一性,现在构建会拒绝在原始包装器上使用synchronized(PR #5338)。 - 代价:二进制增加 17 KB。如果出现任何问题,使用版本化构建回滚并告知我们。
本周其余内容
性能工作是头条,但本周不止这一个 PR:
- 周六。 证书向导现在是一个独立工具,采用不同的方式处理 Apple 登录。它应该能终结旧向导的登录中断问题。PR #5339。
- 周日。 AR 和 VR 支持,包括一个可以在模拟器中调试的模拟 AR 房间。PR #5335。
- 周一。 自动化商店提交,将你的列表作为代码,包括华为 AppGallery。同一篇文章还涵盖了组织账户和构建云中的自助账户删除。PR #5353。
关于本周还有一个说明。我们最近大部分时间投入了两个非常大的 PR,而这篇帖子里的 PR 是其中小得多那个。因此从功能数量上看,这是一个相对缓慢的一周,但每一处变化都意义重大,而且还有更多内容正在推进。很快会有更多消息。
本文《如何通过作弊(但不是那种作弊)击败 HotSpot 性能》首次发表于 foojay。