探讨使用 Mandrel 23.1 生成的本机可执行文件为何比使用 Mandrel 23.0 生成的更大

本文是 探索为什么使用 Mandrel 23.0 生成的原生可执行文件比使用 Mandrel 22.3 生成的要大 的续篇。

从 Quarkus 3.5 开始,默认的 Mandrel 版本已从 23.0 更新到 23.1。

这次更新带来了许多 bug 修复以及新功能,例如:

  1. AMD64 上的 外部函数和内存 API 下调 (属于“Project Panama”,JEP 442) 的预览。必须使用 --enable-preview 启用。
  2. 新的选项 -H:±IndirectBranchTargetMarker,用于使用 endbranch 指令标记 AMD64 上的间接分支目标。这是未来 Intel CET 支持的先决条件。
  3. 当尝试创建代理类但未在构建时注册时,抛出 MissingReflectionRegistrationError,而不是 VMError
  4. 支持 -XX:+HeapDumpOnOutOfMemoryError
  5. 新的 --parallelism 选项,用于控制构建过程中使用的线程数。
  6. 类初始化器模拟:未标记在镜像构建时初始化的类的类初始化器会在镜像构建时进行模拟,以避免在镜像运行时执行它们。
  7. 以及 更多

然而,这也带来了一个不受欢迎的副作用。使用 Mandrel 23.1 生成的原生可执行文件比使用 Mandrel 23.0 生成的要大。为了更好地理解为什么会这样,我们进行了彻底的分析,将尺寸增加归因于 Mandrel 代码库中的特定更改。

简而言之

根据我们的分析,二进制文件尺寸的增加归因于两项不同的更改,这两项更改对于在使用 async-sampler 时获得更准确的配置文件是必需的。

更好地理解生成的原生可执行文件之间的差异

为了进行分析,我们使用了 Quarkus startstop 测试 (特别是提交 a8bae846881607e376c7c8a96116b6b50ee50b70),它会生成、启动、测试和停止小型 Quarkus 应用程序,并测量各种与时间相关的指标(例如,首次 OK 请求时间)和内存使用情况。我们通过以下方式获取测试:

git clone https://github.com/quarkus-qe/quarkus-startstop
cd quarkus-startstop
git checkout a8bae846881607e376c7c8a96116b6b50ee50b70

并使用以下命令构建:

mvn clean package -Pnative -Dquarkus.version=3.5.0\
    -Dquarkus.native.builder-image=quay.io/quarkus/ubi-quarkus-mandrel-builder-image:jdk-17

将构建器镜像标签分别更改为 jdk-20jdk-21,以便使用 Mandrel 23.0 (基于 JDK 20) 和 Mandrel 23.1 (基于 JDK 21) 进行构建。

我们之所以还使用 jdk-20,尽管它已弃用,是为了在不改变代码库的情况下(jdk-17jdk-20 基于相同的 Mandrel 源代码,但使用不同的基础 JDK 版本构建)查看基础 JDK 的影响。

查看构建输出 (由 Quarkus 在 target/my-app-native-image-sources/my-app-build-output-stats.json 中生成),三个构建之间的主要差异在于以下指标:

Mandrel 版本 23.0.2.1 (jdk-17) 23.0.1.2 (jdk-20) 23.1.1.0 (jdk-21) jdk-17 到 jdk-20 的增长百分比 jdk-20 到jdk-21 的增长百分比
镜像堆大小 29790208 30982144 33546240 4 8.3
对象计数 351565 353273 356059 0.5 0.8
资源大小 169205 174761 175392 3.3 0.36
资源计数 28 28 79 0 182
镜像总大小 60006728 61734352 64224536 2.88 4

这表明基础 JDK 在镜像尺寸增加中起着重要作用,但仍然存在疑问,即在 jdk-20jdk-21 之间生成的二进制文件尺寸的进一步增加是由于 JDK 的差异还是由于 Mandrel 本身的更改。

尽管 Mandrel 23.0 (jdk-20) 和 Mandrel 23.1 (jdk-21) 之间的资源计数有所增加,但资源大小受到的影响不大,这一点也很有趣。因此,我们将分析重点放在镜像堆大小上,该大小在不同 Mandrel 版本之间与对象计数不成比例地增加,这表明要么某些对象变得更大了,要么添加的少数新对象相当大。

仪表板

GraalVM 和 Mandrel 提供了 -H:+DashboardAll-H:+DashboardJson 标志,可用于生成包含有关生成的原生可执行文件的更多信息的仪表板。生成的仪表板包含许多指标,外观如下:

{
  "points-to": {
    "type-flows": [
      ...
    ]
  },
  "code-breakdown": {
    "code-size": [
      {
        "name": "io.smallrye.mutiny.CompositeException.getFirstOrFail(Throwable[]) Throwable",
        "size": 575
      },
      ...
    ]
  },
  "heap-breakdown": {
    "heap-size": [
      {
        "name": "Lio/vertx/core/impl/VerticleManager$$Lambda$bf09d38f5d19578a0d041ffd0a524c1cbe1843df;",
        "size": 24,
        "count": 1
      },
      ...
    ]
  }
}

使用上述标志,我们使用 Mandrel 23.1 和 23.0 生成仪表板并比较结果。

要使用 Mandrel 23.0 生成仪表板,我们使用以下命令:

mvn package -Pnative \
  -Dquarkus.version=3.5.0 \
  -Dquarkus.native.builder-image=quay.io/quarkus/ubi-quarkus-mandrel-builder-image:jdk-20 \
  -Dquarkus.native.additional-build-args=-H:+DashboardAll,-H:+DashboardJson,-H:DashboardDump=path/to/23.0.dashboard.json

同样,要使用 Mandrel 23.1 生成仪表板,我们使用以下命令:

mvn package -Pnative \
  -Dquarkus.version=3.5.0 \
  -Dquarkus.native.builder-image=quay.io/quarkus/ubi-quarkus-mandrel-builder-image:jdk-21 \
  -Dquarkus.native.additional-build-args=-H:+DashboardAll,-H:+DashboardJson,-H:DashboardDump=path/to/23.1.dashboard.json

注意:请确保将 path/to/ 更改为您希望仪表板 json 文件存储的路径,每个文件大约为 370MB。

分析和可视化数据

为了处理仪表板中的数据,我们使用了 Jupyter notebook,正如本篇文章中所做的那样。要获取 notebook,请访问 此链接

加载 json 文件中的数据

import json
import pandas as pd

# load data from JSON file
with open('23.0.dashboard.json', 'r') as f:
    data23_0 = json.load(f)
with open('23.1.dashboard.json', 'r') as f:
    data23_1 = json.load(f)

# create dataframes from json data
df23_0 = pd.DataFrame(data23_0)
df23_1 = pd.DataFrame(data23_1)

堆镜像变大是因为堆中的对象比以前大了,还是因为我们在其中存储了更多对象?

# Get heap-size lists from dataframes
heap_size_23_0 = df23_0['heap-breakdown']['heap-size']
heap_size_23_1 = df23_1['heap-breakdown']['heap-size']

# create dataframes from heap_size lists
heap_df23_0 = pd.DataFrame(heap_size_23_0).rename(columns={'size': 'size-23.0', 'count': 'count-23.0'})
heap_df23_1 = pd.DataFrame(heap_size_23_1).rename(columns={'size': 'size-23.1', 'count': 'count-23.1'})

平均对象大小是多少?

print("Average object size for Mandrel 23.0: {:.2f}".format(heap_df23_0['size-23.0'].mean()))
print("Average object size for Mandrel 23.1: {:.2f}".format(heap_df23_1['size-23.1'].mean()))
Average object size for Mandrel 23.0: 8137.46
Average object size for Mandrel 23.1: 8880.06

每种情况下的最小和最大对象大小是多少?

print("Minimum object size for Mandrel 23.0:", heap_df23_0['size-23.0'].min())
print("Minimum object size for Mandrel 23.1:", heap_df23_1['size-23.1'].min())
max_size_23_0 = heap_df23_0['size-23.0'].max()
max_size_23_1 = heap_df23_1['size-23.1'].max()
print("Maximum object size for Mandrel 23.0:", max_size_23_0)
print("Maximum object size for Mandrel 23.1:", max_size_23_1)
Minimum object size for Mandrel 23.0: 16
Minimum object size for Mandrel 23.1: 16
Maximum object size for Mandrel 23.0: 14149496
Maximum object size for Mandrel 23.1: 16933168

我们发现,使用 Mandrel 23.1 编译时的最大对象大小比使用 Mandrel 23.0 编译时的最大对象大小大约大 2.6MB。

max_size_diff = (max_size_23_1 - max_size_23_0) / (1024 * 1024)
print("Max size difference in MB: {:.2f}".format(max_size_diff))
Max size difference in MB: 2.65

哪些对象是较大的对象?

因此,接下来我们搜索以查看哪些对象在两种情况下都较大,以及它们在另一个 Mandrel 版本中的相应大小。

max_size_23_0_rows = heap_df23_0.loc[heap_df23_0['size-23.0'] == max_size_23_0]
print("Objects with size equal to max_size_23_0 in heap_size_23_0:")
print(max_size_23_0_rows)
Objects with size equal to max_size_23_0 in heap_size_23_0:
     name  size-23.0  count-23.0
1340   [B   14149496      110914
max_size_23_1_rows = heap_df23_1.loc[heap_df23_1['size-23.1'] == max_size_23_1]
print("Objects with size equal to max_size_23_1 in heap_size_23_1:")
print(max_size_23_1_rows)
Objects with size equal to max_size_23_1 in heap_size_23_1:
     name  size-23.1  count-23.1
1351   [B   16933168      111454

不出所料,我们发现两种情况下尺寸最大的对象类型是 [B,即字节数组。

更多大小相似的字节数组,还是几个较大的字节数组?

接下来,我们查看两个版本中字节数组的平均大小,以了解尺寸增加是由于向镜像添加了大小相似的数组,还是仅仅是几个较大的数组。

max_size_23_0_row = max_size_23_0_rows.iloc[0]
max_size_23_1_row = max_size_23_1_rows.iloc[0]

print("Average size of byte arrays in Mandrel 23.0: {:.2f}".format(max_size_23_0_row['size-23.0']/max_size_23_0_row['count-23.0']))
print("Average size of byte arrays in Mandrel 23.1: {:.2f}".format(max_size_23_1_row['size-23.1']/max_size_23_1_row['count-23.1']))
Average size of byte arrays in Mandrel 23.0: 127.57
Average size of byte arrays in Mandrel 23.1: 151.93

我们发现,使用 Mandrel 23.1 构建时的平均字节数组大小更大,这表明一些较大的字节数组被添加到镜像堆中。

生成堆转储并在 Java Mission Control (JMC) 中进行分析

由于仪表板未提供更多信息,我们使用 -Dquarkus.native.additional-build-args=-R:+DumpHeapAndExit 重新构建我们的测试,同时使用两种 Mandrel 版本。此选项指示生成的原生镜像创建堆转储并退出。

mvn package -Pnative \
  -Dquarkus.version=3.5.0 \
  -Dquarkus.native.builder-image=quay.io/quarkus/ubi-quarkus-mandrel-builder-image:jdk-20 \
  -Dquarkus.native.additional-build-args=-R:+DumpHeapAndExit
...
./target/quarkus-runner
Heap dump created at 'quarkus-runner.hprof'.
mv quarkus-runner.hprof quarkus-runner-23-0.hprof

我们使用 jdk-21 标签对 Mandrel 23.1 执行相同操作,并在 Java Mission Control (JMC) 中打开转储。要安装 JMC,可以使用 sdk install jmc

启动 JMC 后,我们导航到 File->Open 并选择我们刚刚生成的堆转储。

JMC File -> Open

加载堆转储后,我们单击 byte[] 类以过滤结果,并专注于此类型的对象。

JMC focus on byte[] for 23.1

此时,在窗口的右侧,我们可以看到按它们引用的字节数组总大小排序的引用者。

JMC focus on byte[] for 23.1

我们发现,在使用 Mandrel 23.1 时,大部分字节数组由 com.oracle.svm.core.code.ImageCodeInfo.codeInfoEncodings (12%) 和 com.oracle.svm.core.code.ImageCodeInfo.frameInfoEncodings (11%) 引用,而在使用 Mandrel 23.0 时,相应百分比为 12% 和 6%。

JMC focus on byte[] for 23.0

我们还发现,在使用 Mandrel 23.1 时,com.oracle.svm.core.code.ImageCodeInfo.frameInfoEncodings 的大小比使用 Mandrel 23.0 时的相应大小大约大 2.5MB。

将二进制尺寸增加归因于特定代码更改

因此,我们使用以下方法将搜索重点放在 Mandrel 源代码中可能影响帧信息编码的更改上:

git log -- substratevm/src/com.oracle.svm.core/src/com/oracle/svm/core/code/FrameInfo*

通过这种方式,我们检测到了以下两个拉取请求:

  1. 添加对顶级帧的分析支持,该支持向镜像添加了约 1MB 的数据。
  2. ProfilingSampler 不需要局部变量值 (特别是 提交“始终在帧信息中存储 bci”),该支持向镜像添加了约 1.7MB 的数据。

这两项更改对于提高 async-sampler 的准确性都是必需的。

结论

与 Quarkus 从 22.3 升级到 23.0 时一样,我们观察到从 23.0 到 23.1 的过程中,生成的原生可执行文件尺寸有所增加。同样,导致二进制文件尺寸增加的更改似乎是合理且有据可查的。随着 native-image 变得越来越成熟和功能丰富,似乎不可避免地会增加生成二进制文件的尺寸。

如果您认为此类信息应仅在用户选择加入时包含,请在 此讨论中提供您的反馈。