产品资讯

编译速度提升 18%,性能不受影响

阅读用时:8 分钟

Android 运行时 (ART) 团队在不影响已编译代码或任何峰值内存回归的情况下,将编译时间缩短了 18%。这项改进是我们 2025 年计划的一部分,旨在缩短编译时间,同时不牺牲内存用量或编译后代码的质量。

优化编译时速度对于 ART 至关重要。例如,在进行即时 (JIT) 编译时,它会直接影响应用的效率和整体设备性能。编译速度越快,优化生效的时间就越短,从而带来更流畅、响应更迅速的用户体验。此外,对于 JIT 和预先 (AOT) 编译,编译时速度的提升意味着在编译过程中资源消耗的减少,从而延长电池续航时间并改善设备散热,尤其是在低端设备上。

其中一些编译时速度改进已在 2025 年 6 月的 Android 版本中推出,其余改进将在年底的 Android 版本中提供。此外,所有使用 Android 12 及更高版本的用户都可以通过 Mainline 更新获得这些改进。

优化优化编译器

优化编译器始终是一项需要权衡取舍的任务。您无法免费获得速度,必须放弃一些东西。我们为自己设定了一个非常明确且富有挑战性的目标:加快编译器速度,但不能引入内存回归,最重要的是,不能降低其生成的代码的质量。如果编译器速度更快,但应用运行速度更慢,我们就失败了。

我们愿意投入的唯一资源是我们自己的开发时间,以便深入挖掘、调查并找到符合这些严格标准的巧妙解决方案。让我们仔细看看我们如何寻找有待改进的领域,以及如何找到各种问题的正确解决方案。

寻找值得进行的潜在优化

在开始优化某个指标之前,您必须能够精准衡量该指标。否则,您将永远无法确定自己是否有所改进。幸运的是,只要您采取一些预防措施,例如在更改前后使用同一设备进行测量,并确保设备不会进入过热保护,编译时间速度就相当稳定。除此之外,我们还有编译器统计信息等确定性衡量指标,可帮助我们了解底层发生了什么情况。

 

由于我们为这些改进牺牲的资源是开发时间,因此我们希望能够尽可能快地进行迭代。这意味着,我们选取了一些具有代表性的应用(包括第一方应用、第三方应用和 Android 操作系统本身)来制作解决方案原型。后来,我们通过广泛的手动和自动测试验证了最终实现的价值。

 

有了这组精心挑选的 APK,我们就可以在本地触发手动编译,获取编译配置文件,并使用 pprof 可视化我们花费时间的位置。

image.png

pprof 中配置文件火焰图的示例

pprof 工具非常强大,可用于对数据进行切片、过滤和排序,以便查看哪些编译器阶段或方法占用了大部分时间。我们不会详细介绍 pprof 本身;您只需知道,如果条形图越大,则表示编译花费的时间越长。

其中一个视图是“自下而上”视图,您可以在其中查看哪些方法占用了大部分时间。在下图中,我们可以看到一个名为 Kill 的方法,该方法占编译时间的 1% 以上。本博文稍后还将讨论其他一些热门方法。

image.png

从底部向上查看个人资料

在我们的优化编译器中,有一个阶段称为全局值编号 (GVN)。您不必担心它作为一个整体的作用,但相关部分是了解它有一个名为“Kill”的方法,该方法会根据过滤条件删除一些节点。由于必须遍历所有节点并逐个检查,因此非常耗时。我们注意到,在某些情况下,无论当时有多少节点处于活跃状态,我们都能提前知道检查结果为 false。在这种情况下,我们可以完全跳过迭代,将该值从 1.023% 降至约 0.3%,并将 GVN 的运行时提高约 15%。

实施有价值的优化

我们介绍了如何衡量以及如何检测时间花费在何处,但这只是开始。下一步是了解如何优化编译所花费的时间。

通常,在上述 `Kill` 这样的情况下,我们会查看如何迭代节点,并通过例如并行处理或改进算法本身来加快迭代速度。事实上,我们一开始就是这样尝试的,只有在找不到任何可做的事情时,我们才突然意识到,解决方案是(在某些情况下)根本不进行迭代!在进行此类优化时,很容易只见树木不见森林。

在其他情况下,我们使用了多种不同的技术,包括:

  • 使用启发法来决定优化是否会产生有价值的结果,因此可以跳过
  • 使用额外的数据结构来缓存计算出的数据
  • 更改当前数据结构以提高速度
  • 延迟计算结果,以避免在某些情况下出现循环
  • 使用合适的抽象层级 - 不必要的功能可能会降低代码速度
  • 避免在多次加载中追逐常用指针

如何判断优化是否值得尝试?

妙就妙在,您无需这样做。在检测到某个区域消耗大量编译时间并投入开发时间尝试改进后,有时您可能找不到解决方案。也许是无事可做,也许是实现起来太费时间,也许是会显著降低其他指标,也许是会增加代码库的复杂性,等等。请注意,虽然您可以在这篇博文中看到许多成功的优化,但还有无数优化未能实现。

如果您也遇到类似情况,请尝试估计一下,通过尽可能少的努力,您能将指标提高多少。这意味着,按顺序:

  1. 根据已收集的指标或仅凭直觉进行估算
  2. 使用粗略的快速原型进行估算
  3. 实施解决方案。

别忘了考虑估算解决方案的缺点。例如,如果您要依赖额外的数据结构,您愿意使用多少内存?

深入了解

下面,我们来了解一下我们实施的一些变更。

我们实施了一项更改,以优化名为 FindReferenceInfoOf 的方法。此方法之前一直对向量执行线性搜索来查找条目。我们更新了该数据结构,使其按指令的 ID 进行索引,这样 FindReferenceInfoOf 的时间复杂度将为 O(1),而不是 O(n)。此外,我们还预先分配了向量,以避免调整大小。我们略微增加了内存,因为我们必须添加一个额外的字段来统计插入到向量中的条目数量,但这是一个很小的牺牲,因为峰值内存并没有增加。这使我们的 LoadStoreAnalysis 阶段加快了 34-66%,从而使编译时间缩短了约 0.5-1.8%。

我们在多个位置使用了 HashSet 的自定义实现。创建此数据结构需要相当长的时间,我们找到了原因。多年前,此数据结构仅用于少数使用非常大的 HashSet 的位置,并经过调整以针对这些位置进行优化。不过,如今它被反向使用,只有少数条目,且生命周期较短。这意味着,我们创建这个巨大的 HashSet 浪费了周期,但我们只使用了其中的几个条目,然后就将其舍弃了。通过此变更,我们将编译时间缩短了约 1.3-2%。此外,由于我们不再使用之前那么大的数据结构,内存使用量减少了约 0.5-1%。

我们通过按引用将数据结构传递给 lambda,避免了复制这些数据结构,从而将编译时间缩短了大约 0.5-1%。这是原始审核中遗漏的问题,在我们的代码库中存在了多年。正是通过查看 pprof 中的配置文件,我们才注意到这些方法正在创建和销毁大量数据结构,这促使我们对其进行调查和优化。

我们通过缓存计算值加快了写入编译后输出的阶段,这相当于将总编译时间缩短了约 1.3-2.8%。遗憾的是,额外的簿记工作量过大,我们的自动化测试也提醒我们出现了内存回归。后来,我们再次查看了同一段代码,并实现了新版本,该版本不仅解决了内存回归问题,还进一步缩短了编译时间,缩短幅度约为 0.5-1.8%!在第二次更改中,我们必须重构并重新构想此阶段应如何运作,才能摆脱两种数据结构中的一种。

在优化编译器中,我们有一个阶段会内联函数调用,以获得更好的性能。为了选择要内联的方法,我们在进行任何计算之前使用启发式方法,并在完成工作后但在最终确定内联之前进行最终检查。如果其中任何一个检测到内联不值得(例如,会添加过多的新指令),那么我们就不会内联方法调用。

我们将两项检查从“最终检查”类别移至“启发式”类别,以便在进行任何耗时的计算之前估计内嵌是否会成功。由于这只是一个估计值,因此并不完美,但我们已验证,新的启发式方法涵盖了之前内嵌的 99.9% 的内容,且不会影响性能。其中一种新的启发式方法与所需的 DEX 寄存器(改进幅度约为 0.2-1.3%)有关,另一种与指令数量(改进幅度约为 2%)有关。

我们有一个在多个位置使用的 BitVector 自定义实现。我们已将可调整大小的 BitVector 类替换为更简单的 BitVectorView,用于某些固定大小的位向量。这样可以消除一些间接寻址和运行时范围检查,并加快位向量对象的构建速度。

此外,BitVectorView 类已根据底层存储类型进行模板化(而不是像旧版 BitVector 那样始终使用 uint32_t)。这样一来,某些操作(例如 Union())便可在 64 位平台上同时处理两倍的位。在编译 Android OS 时,受影响函数的样本总数减少了 1% 以上。此操作是通过多次更改完成的 [123456]

如果我们详细讨论所有优化,那可能要在这里待上一整天!如果您对更多优化感兴趣,请查看我们实施的其他一些更改:

总结

我们一直致力于提高 ART 的编译时速度,并取得了显著的改进,这不仅使 Android 更加流畅高效,还有助于延长电池续航时间并改善设备散热性能。通过认真识别和实施优化,我们证明了在不影响内存使用量或代码质量的情况下,可以大幅缩短编译时间。

我们的历程包括使用 pprof 等工具进行分析、愿意迭代,有时甚至会放弃不太有成效的途径。ART 团队的共同努力不仅显著缩短了编译时间,还为未来的改进奠定了基础。

所有这些改进都将在 2025 年年底的 Android 更新中提供,并且可通过 Mainline 更新在 Android 12 及更高版本中提供。我们希望通过深入了解我们的优化流程,您能对编译器工程的复杂性和回报有更深入的了解!

继续阅读