查看原文
其他

Android运行时更新,为数十亿设备提高内存

郭霖 2024-01-12

The following article is from Android 开发者 Author Android



/   今日科技快讯   /

近日,快时尚公司Shein和拼多多旗下的跨境电商平台Temu向美国客户发运包裹的数量激增,几乎已经与美国几家知名零售商不相上下。根据包裹运输咨询公司ShipMatrix的数据显示,这两家快速增长的购物平台在美国平均每天都要发运大约100万个包裹。虽然这个数字在美国每天发运包裹总数中所占比例还很小,但却在快速增长中。Temu和Shein每天为美国客户递送包裹的数量已经是电视购物网络、在线零售商QVC和HSN的两倍多,后两家公司每天在全美各地运送约41.3万个包裹。亚马逊平均每天在美国运送超过2000万个包裹。

/   前言   /

Android 运行时 (ART) 执行由 Java 或 Kotlin 语言编写的应用和系统服务生成的 Dalvik 字节码。我们不断改进 ART,以生成规模更小、性能更强的代码。ART 贯穿于各个 Android 应用中,因此改进 ART 可以从整体上提升系统性能和用户体验。在本文中,我们将与您分享相关优化环节,在不影响性能的情况下缩减代码大小。

代码大小是我们关注的关键指标之一,因为生成的文件越小,越省内存 (包括 RAM 和存储空间)。随着新版 ART 的推出,我们估计可在每台设备上为用户节省约 50-100MB 的空间。这可能刚好能够满足您更新喜爱的应用或下载一个新应用的需求。由于 ART 可从 Android 12 开始更新,这些优化环节已适用于超过 10 亿台设备,我们在全球范围内为这些设备节省了 47-95 PB (4700-9500 万 GB)!

本文中提到的所有改进均为开源内容,属于 ART 主线更新,因此您甚至不需要完整地更新操作系统,即可获享这些改进。这些更新能够更好地帮助您高效开发!

/   优化编译器 101   /

ART 使用设备端的 dex2oat 工具,将应用从 DEX 格式编译为原生代码。第一步是解析 DEX 代码并生成中间表示法(IR)。通过使用 IR,dex2oat 能够执行许多代码优化。对于这个流水线而言,最后一步是代码生成阶段,dex2oat 会在这一阶段将 IR 转换为原生代码(例如,AArch64 汇编)。

优化流水线包括多个执行阶段,以便每个阶段都专注于一组特定的优化。以常量折叠为例,此优化环节会尝试用常量值替换指令,例如将加法运算 2 + 3 折叠为 5。


IR 可以被输出和可视化,但与 Kotlin 语言代码相比,IR 非常冗长。在本文中,我们将展示使用 Kotlin 语言代码实施的优化环节,但要知道这些优化是针对 IR 代码进行的。

/   优化代码大小   /

针对所有代码大小优化环节,我们对 Google Play 商店中 50 多万个 APK 进行了测试,并汇总了结果。

消除写入屏障

我们推出了名为 "写入屏障消除" 的新优化环节。写入屏障会追踪自垃圾回收器 (GC) 上次检查以来已修改的对象,以便 GC 可以重新访问。例如,对于以下代码:


过去,我们会为每个对象修改发出一个写入屏障,但实际上我们仅仅需要一个写入屏障,原因如下:

  1. 标记将在 o 本身中设置(而非内部对象中);
  2. 垃圾回收不能与这些集合之间的线程交互。

如果指令可能触发 GC(例如调用和挂起检查),我们将无法消除写入屏障。在下面的示例中,我们并不能保证 GC 不需要检查或改进两次修改之间的追踪信息:


实施这一新优化环节有助于将代码大小缩小 0.8%。

隐式的挂起检查

假设我们正在运行若干线程。挂起检查是我们可以暂停线程执行的安全点 (由下图中的房屋表示)。使用安全点的原因很多,其中最重要的是垃圾回收。当发出安全点调用时,线程必须进入安全点,在释放之前都将处于被屏蔽状态。

在此之前,我们的实现方式是显式布尔检查。我们会加载该值,对其进行测试,并在需要时将其划分到安全点分支。


隐式的挂起检查是一个优化环节,无需测试和分支指令。相反,我们只需要执行加载过程:如果线程需要挂起,该加载会报错,并且信号处理程序会将代码重定向到挂起检查处理程序,就像该方法发起了调用一样。


更详细地说,保留寄存器 rX 预加载了线程内的一个地址,其中有一个指向自身的指针。只要不用进行挂起检查,我们就保留该自指向指针。当需要进行挂起检查时,我们会清除指针,在该指针对线程可见后,第一个 LDR rX, [rX] 将加载 null,第二个将出现分段错误。

从本质上来说,挂起请求是要求线程迅速挂起一段时间,因此在等待第二次加载的过程中,出现轻微延迟是可以接受的。

此优化环节可将代码大小缩小 1.8%。

合并 return 语句

已编译方法通常具有入口框架。如果这些方法具备该框架,则需在返回结果时予以解构,这又叫做 "exit frame"。如果一个方法包含多个 return 指令,它将生成多个 exit frame,每个 return 指令对应一个 exit frame。

通过将 return 指令合而为一,我们能够获得一个 return 点,并且能够删除多余的 exit frame。这对于具有多个 return 语句的 Switch/Case 代码特别有帮助。


合并 return 语句可将代码大小缩小 1%。

其他优化环节改进

我们改进了多个现有的优化环节。在本文中,我们将这些优化环节划分在了同一部分中,但实际上它们彼此独立。以下部分中的所有优化环节有助于将代码大小缩小 5.7%。

代码下沉

代码下沉是一个优化环节,可将指令下推到不常见的分支,例如以 throw 语句结尾的路径。这样做是为了减少在可能不会用到的指令上浪费循环次数。

我们通过 try catch 语句改进了图中的代码下沉:我们现在支持下沉代码,只要不将其下沉到与原始 try 语句不同的 try 语句中即可(或者,如果代码一开始不属于任何 try 语句,则可放入任意 try 语句中)。


在第一个示例中,我们可以下沉 Object 创建代码,因为我们仅会在 if(flag) 中用到这一语句,而不会在其他路径中使用,并且这二者位于同一 try 语句中。实施这一更改后,在运行时,Object () 只会在 flag 为 true 时运行。在不涉及太多技术细节的情况下,我们可以下沉的是实际的对象创建语句,但是 Object 类的加载仍然位于 if 条件之前。这很难用 Kotlin 代码来展示,因为同一行 Kotlin 代码在 ART 编译器级别会变成多条指令。

在第二个示例中,我们不能下沉代码,因为我们将把实例创建 (可能会抛出错误) 移动到另一个 try 语句中。

代码下沉侧重于运行时性能优化,但可以帮助减轻寄存器压力。通过使指令更接近其用途,在某些情况下我们可以使用更少的寄存器。使用更少的寄存器意味着更少的移动指令,最终有助于缩减代码大小。

循环优化

循环优化有助于减少编译时的循环次数。在下面的示例中,foo 中的循环会将 a 乘以 10,循环 10 次。这就相当于将 a 乘以 100。下图使用了 try catch 语句,我们在其中使用了循环优化。


在 foo 中,我们可以优化循环,因为 try 语句和 catch 语句并不相关。

然而,对于 bar 或 baz,我们则无法进行优化。如果循环中有一个 try 语句,或者整个循环都出现在 try 语句内部,那么弄清楚循环将采用哪个路径并非易事。

无效代码删除 – 移除不需要的 try 代码块

我们通过实施优化环节来移除不包含抛出指令的 try 代码块,从而改进了无效代码删除阶段。我们还可以删除一些 catch 代码块,只要没有活动的 try 代码块指向它即可。

在下面的示例中,我们在 foo 中内嵌了 bar。借此知道了该区块无法抛出错误。我们可以在之后的优化环节利用这一点并改进代码。


只需从 try catch 中删除无效代码就足够了,不过更好的是,在某些情况下,我们还可以实施其他优化环节。如前文所述,当循环包含 try 或者循环位于 try 内部时,我们不会进行循环优化。通过消除这种冗余的 try/catch,我们可以优化循环语句,生成规模更小和速度更快的代码。


无效代码删除 – SimplifyAlwaysThrows

在无效代码删除阶段,我们实施了名为 SimplifyAlwaysThrows 的优化环节。如果检测到调用总是会抛出错误,我们可以放心地舍弃该方法调用之后的任何代码,因为系统永远不会执行这些代码。

我们还更新了 SimplifyAlwaysThrows,以便处理下图中的 try catch 语句,只要调用本身不在 try 内部即可。如果调用位于 try 内部,我们可能会跳转到 catch 代码块,并且很难找出将要执行的确切路径。


我们还改进了以下方面:

  • 通过查看参数来检测调用何时抛出错误。在左侧,我们将 divide(1, 0) 标记为始终抛出错误,即使这种泛型方法并不总是抛出错误。
  • SimplifyAlwaysThrows 适用于所有调用。之前我们会受到限制,例如不要对导致 if 的调用执行此操作,但我们现在可以摒弃所有限制。


加载存储消除 – 使用 try catch 代码块

加载存储消除 (LSE) 是一个优化环节,可移除冗余的加载与存储。

我们改进了这个过程,以处理图中的 try catch。在 foo 中,如果存储/加载不直接与 try 交互,我们可以正常执行 LSE。在 bar 中,如示例所示,我们要么执行正常路径而不抛出错误,在这种情况下返回 1;要么抛出并捕获错误,然后返回 2。由于每条路径的值都是已知的,因此我们可以删除冗余加载。


加载存储消除 – 使用释放/获取操作

我们改进了加载存储消除,来处理图中的释放/获取操作。这些是易失性加载、存储和监视操作。需要说明的是,这仅意味着我们能够在具有这些操作的图中执行 LSE,但我们并不会移除上述操作。

在示例中,i 和 j 是常规整数,而 vi 是易失性整数。在 foo 中,我们可以跳过加载值,因为集合和加载之间不存在释放/获取操作。在 bar 中,这二者之间存在易失性操作,因此我们无法消除正常加载。需要注意的是不使用易失性加载操作并不重要,因为我们无法消除获取操作。


此优化环节同样适用于易失性存储和监视操作 (Kotlin 中已同步的代码块)。

新的内嵌启发法

我们的内嵌过程包含众多启发法。有时我们会因为方法太大而不予以内嵌,而有时会因为方法太小而执行强制内嵌(例如 Object 初始化这样的空方法)。

我们实现了一种新的内嵌启发法:不要内嵌会导致抛出错误的调用。如果我们知道会抛出错误,我们将跳过内嵌这些方法,因为抛出错误本身的成本很高,所以内嵌该代码路径并不划算。

对于下列三个方法系列,我们会跳过内嵌过程:

  • 在抛出错误之前计算并输出调试信息。
  • 内嵌错误构造函数本身。
  • 在我们的优化编译器中,存在重复的 finally 代码块。一个用于正常情况 (即 try 没有抛出错误),还有一个用于异常情况。这样做是因为在异常情况下,我们必须捕获和执行 finally 代码块,然后重新抛出错误。异常情况下的方法不会被内嵌,但正常情况下的方法会被内嵌。


常量折叠

常量折叠是一个优化环节,会在可行的情况下将操作转变为常量。我们实现了一个优化环节,传播在 if guard 语句中使用时已知为常量的变量。图中存在多个常量,我们可以在稍后实施更多优化环节。

在 foo 中,我们知道 a 在 if guard 语句中的值为 2。我们可以传播这一信息,进而推导出 b 的值一定是 4。同样地,在 bar 中,我们知道 cond 在 if 分支下必为 true,在 else 情况下必为 false(简化图表)。


/   汇总   /

如果我们充分应用本文中介绍的所有代码大小优化环节,我们的代码大小将缩减 9.3%!

从长远来看,一部手机可以有约为 500M-1GB 的优化代码 (实际数字可能会更高或更低,这具体取决于您安装的应用数量,以及您安装了哪些特定的应用),因此这些优化环节可为每个设备节省约 50-100MB 的空间。这些优化环节适用于超过 10 亿台设备,也就意味着这可以在全球范围内节省 47-95 PB!

推荐阅读:
我的新书,《第一行代码 第3版》已出版!
原创:写给初学者的Jetpack Compose教程,Lazy Layout
Android帝国之进程杀手:lmkd

欢迎关注我的公众号
学习技术或投稿


长按上图,识别图中二维码即可关注

继续滑动看下一个

Android运行时更新,为数十亿设备提高内存

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存