Ohhnews

分类导航

$ cd ..
foojay原文

Leyden项目如何提升Java性能:AOT缓存实践指南

#java#leyden#aot缓存#性能优化#openjdk

目录

在本系列三篇博文的第一部分中,我们介绍了 OpenJDK 在降低应用程序“启动”、“预热”和“初始内存占用”成本方面所面临的具体性能挑战,并概述了 Leyden 为解决这些挑战所做的工作。

第 2 部分将介绍如何使用 Leyden 提供的新 AOT 功能,并展示测试结果,这些结果表明已经取得了非常显著的进展,并将持续改进。

第 3 部分将更详细地说明 Leyden 拟议解决方案的运作方式,并首次展示相关工具,帮助您评估由此带来的收益,并优化您的应用程序,以充分利用 Leyden 的优势。

如何使用 AOT 缓存

要使用 AOT 缓存(JDK 25+),您需要在应用程序启动命令中添加一些 JVM 参数。有两种实现方式,分为 2 步或 3 步。

联合训练与组装步骤 —— AOT 缓存的写入是在训练运行结束时,通过分叉(forked)的 Java 运行时执行的:

  1. 训练+组装运行java -XX:AOTCacheOutput=${aot-cache-file} -jar app.jar
  2. 生产运行java -XX:AOTCache=${aot-cache-file} -jar app.jar

两步模型中的第 1 步会运行您的应用程序直到其退出(无论是通过应用程序内置的退出机制,还是简单地在控制台输入 Ctrl-C)。此时,会分叉出一个独立的组装 JVM,用于消耗训练期间收集的数据,并使用通过 AOTCache 命令行选项提供的名称生成 AOT 缓存。训练 JVM 会等待组装 JVM 完成文件写入后,才会完成自身的退出。

第 2 步使用 AOTCache 命令行选项指定的 AOT 缓存运行生产应用程序。

分离训练与组装步骤 —— 允许独立执行组装运行,而不会延迟训练运行的退出:

  1. 训练运行java -XX:AOTMode=record -XX:AOTConfiguration=${aot-cache-conf-file} -jar app.jar
  2. 组装运行java -XX:AOTMode=create -XX:AOTConfiguration=${aot-cache-conf-file} -XX:AOTCacheOutput=${aot-cache-file} -jar app.jar
  3. 生产运行java -XX:AOTCache=${aot-cache-file} -jar app.jar

三步模型允许您将训练和组装作为独立的步骤进行管理。

第 1 步运行您的应用程序直到其退出,此时训练期间收集的数据会被转储到使用 AOTConfiguration 命令行选项指定的 AOT 配置文件中。

在第 2 步中,此训练数据被传递给一个新的 JVM,并用于生成 AOT 缓存到 AOTCacheOutput 命令行选项指定的文件中。

第 3 步使用 AOTCache 命令行选项指定的 AOT 缓存运行生产应用程序。

三步工作流有时更可取,因为它允许训练 JVM 更快地退出。即使不是瞬间完成,转储训练数据通常也很快。生成 AOT 缓存需要更长的时间,因为在排序和以满足 JVM 需求的方式布局数据方面涉及更多的工作。

此外,在 Leyden 的预发布版本中,组装 JVM 将对包含在缓存中的所有方法执行“洁净室(cleanroom)”编译,并可能在多个编译级别上进行编译。这增加了缓存生成步骤的时间。

如何正确执行训练运行?

训练应用程序并生成 AOT 缓存的最佳方式是金丝雀部署(canary deployment),即在启用了训练的真实生产环境中运行应用程序,让其在运行过程中收集训练数据。然而,这并非总是可行,特别是在没有磁盘写入权限的容器化生产环境中。

根据您的部署方式,这可能就是您选择三步训练模型的情况,它允许您的训练 JVM 快速退出,并将组装工作留给后续的独立部署。请注意,组装 JVM 不会运行您的应用程序代码,因此不需要访问网络或数据库等资源。

记录对应用程序的请求并在测试服务器上重放(实时或延迟重放)也是生成 AOT 缓存的极好方法,因为它准确地再现了您在生产环境中预期的行为。或者,您可以生成模拟真实生产环境行为的合成请求数据,尽管这可能会降低生成的 AOT 缓存资产的相关性或准确性。

如果您有强大的测试框架,并且正在使用 Quarkus,您始终可以在集成测试期间生成 AOT 缓存。请注意,您需要重复运行这些方法(通常需要数千次调用)以生成适当的编译优化。

当训练运行尽可能接近生产运行时,效果最好。无论采用何种训练方法,生产出的缓存只有在相同的 JVM 和相同的 JVM 命令行选项下运行,才能在生产环境中使用。

您可以在生产运行类路径的末尾添加额外的 jar 包,但初始部分必须与训练期间提供的类路径相同。

在撰写本文时,您还需要部署在相同的 CPU 系列和操作系统上。在未来的版本中,由于缓存将包含编译后的代码,生产环境的硬件必须实现与训练运行所用硬件完全相同的 CPU 特性。如果 CPU 特性不完全相同,编译后的代码和存根代码资产将被忽略(其他缓存资产仍然可用)。

请记住,在生成缓存时要遵循这些基本约束:相同的硬件、相同的 Java 版本、相同的操作系统以及相同的 JVM 参数。

现在应该在 Java 中使用 AOT 缓存吗?

简短的回答是:是的

无论您的应用程序现在是否能显著提速,或者您是否有兴趣通过测试来帮助 Leyden 的开发朝着您关注的方向发展,您都应该开始使用 AOT 缓存。

请注意,您至少需要 JDK 25 才能使用它。性能增益会随着每个新的 JDK 版本而递增。您通过 Leyden 获得的实际改进在很大程度上取决于您的应用程序及其使用方式。

让我们看一些示例。我们将基于 JDK 26 运行它们。

繁重的数学计算示例

首先,我们将使用一个基准测试应用程序,它通过 REST API 执行繁重的数学运算。我们将对该应用程序进行两次训练,以比较不同的训练方式如何影响生产环境的性能。

此应用程序利用了 Quarkus 的 aot-jar,该 jar 针对 Leyden 进行了优化,并从 3.32.0 版本开始提供。

我们将使用一次训练运行,随机调用以下 URL:

  • /nqueens/16:计算 16 棋盘大小的 N 皇后问题
  • /fibonacci/100:计算输入为 100 的斐波那契数列
  • /nqueens:计算棋盘大小为 16 或 8 的 N 皇后问题
  • /fibonacci:计算 1 到 100 之间随机数的斐波那契数列

其理念是模拟一种部分随机的负载(正如真实用户的 API 请求),但对特定的分支或循环展开大小有偏好。

我们将进行两次训练,一次是 1000 个请求,另一次是 60,000 个请求。这应该能体现不同的训练量如何影响最终性能。我们将在 Linux 机器上运行该应用程序,并为应用程序分配 2 个核心。

由于 Java 26 在 AOT 缓存中存储的内容,我们可以推断,在比较启动时间(应用程序启动和打开端口)时,不同训练之间的差异不会太大,因为初始化期间运行的大多数代码只运行一次,所以增加训练请求数量不会改善初始化时间。随着 AOT 缓存中包含更多资产,这种情况在未来的开发中可能会改变。

通过使用 Java AOT 缓存诊断工具,我们可以比较每次训练生成的缓存内容。

1000 次请求训练60,000 次请求训练
[LOADING...][LOADING...]

正如预期的那样,两次训练似乎缓存了相同数量的元数据(超过 99% 的已使用类),因为在这两种情况下加载到内存中的代码应该大致相同(抛出的某些超时或运行时异常可以解释差异)。这意味着使用任何生成的缓存,启动时间应该是相似的。

60,000 次请求的训练拥有更多经过分析并在更高级别编译的方法,因为它有更长的时间进行分析和优化。这应该会导致更好的预热时间表现。

无论如何,与常规 Java 部署相比,我们应该能注意到启动时间的改善,因为我们在 AOT 期间已经缓存了大量的元数据、配置文件、链接数据和部分堆数据。正如我们在下图中看到的,首次响应时间(包括初始化)已经缩短了一半。

[LOADING...]

另一个有趣的指标是应用程序执行初期响应时间受到干扰的程度和持续时间。即使应用程序完全预热,响应时间也总是存在微小的波动,这通常被称为抖动(jitter)

然而,在预热期间,JVM 必须执行的内务处理工作会显著增加抖动。单个响应可能会被延迟,因为它们需要线程执行一次性事件,例如加载或初始化类、链接调用点或字段访问点,或者更新配置文件数据。后台 JIT 编译也会占用 CPU 周期,可能抢占 Java 线程中的请求处理。最后,早期请求大多会在解释器中相对缓慢地执行,而随着 JIT 编译器交付编译后的代码,后续请求将逐渐响应得更快。

理论上,这正是更长的训练周期产生更大影响的地方。更好的训练结果意味着生产中需要更多的缓存类和堆对象、更多的调用和访问预链接、更多的能够实现更早且更明智编译的方法配置文件数据。因此,我们不仅应该看到与常规 Java 版本相比的改进,还应该看到两个经过训练的缓存之间的差异。

[LOADING...]

上图显示了三种部署方式(传统 Java(无 AOT)、1000 次请求训练的 AOT、60,000 次请求训练的 AOT)中每个请求的响应时间。

在所有情况下,请求速率都是恒定的,并且在服务器的峰值容量范围内。在这三种情况下,抖动都会随着请求计数的增加而缓慢衰减,最终收敛到较低的随机波动。

然而,显而易见的是:

  1. 与使用 AOT 的情况相比,非 AOT 情况下的 JVM 内务处理工作要多得多,达到峰值性能的时间较晚。
  2. 经过良好训练的缓存比训练不足的应用程序抖动更小,即去除了更多的内务处理工作。

[LOADING...]

[LOADING...]

请注意,JDK 26 确实在缓存中存储了训练数据,但并未存储编译后的代码。这意味着,未来版本的 JDK 将在弱训练和强训练方案之间表现出更大的差异。

简单的 REST API

现在,我们将对一个使用 Quarkus 的简单 REST API 应用程序执行相同的操作,该应用连接数据库以提取数据。这一次,我们使用 Leyden premain JVM,它不仅缓存了编译代码,还缓存了前面提到的所有其他 AOT 缓存资产。

我们使用单次训练运行,包含 10,000 个请求,执行重复调用 /fruits 端点的测试。与前面的示例相比,在这种情况下,我们不会观察到推测性编译带来的那么大的优势,因为代码更简单,且入口点始终使用相同的参数调用。但我们仍然可以看到改进。

让我们看看首次响应时间,看看它是否通过 Leyden AOT 得到了改善:

[LOADING...]

此示例中的启动时间比上一个示例慢,因为我们必须初始化数据库连接并加载数据库模型。

现在,让我们看看响应时间,看看得益于 AOT 缓存,预热时间是否也得到了改善。

[LOADING...]

两次运行在最初的几个请求中都会出现抖动,此时大部分内务处理工作已经完成。AOT 运行在第 45 个请求左右抖动的急剧下降表明,此时几乎所有的加载、初始化和链接成本都已满足,并且必要的编译代码已经交付。相比之下,非 AOT 运行即使在 100 个请求后仍然存在抖动,即它尚未完全预热以达到峰值性能。

这些只是展示 Leyden 如何改善您的启动和预热时间的几个示例。

Leyden 能在多大程度上帮助您的应用程序,只能通过尝试才能发现。